package openai_compat import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "time" runtime "iop/packages/go/agentruntime" "iop/packages/go/config" ) func (a *Adapter) applyHeaders(req *http.Request, jsonBody bool) { for k, v := range a.headers { if jsonBody && http.CanonicalHeaderKey(k) == "Content-Type" { continue } req.Header.Set(k, v) } if jsonBody { req.Header.Set("Content-Type", "application/json") } } // prepareRequest is the single operation/auth/model preparation path used by // Models, normalized Chat, Responses, and raw provider tunnels. func (a *Adapter) prepareRequest(ctx context.Context, method, operation, legacyPath string, body []byte, requestHeaders map[string]string, jsonBody bool) (*http.Request, error) { urlStr, err := a.operationURL(operation, legacyPath) if err != nil { return nil, fmt.Errorf("resolve operation URL: %w", err) } preparedBody, err := a.mapProfileBodyModel(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, method, urlStr, bytes.NewReader(preparedBody)) if err != nil { return nil, fmt.Errorf("build request: %w", err) } a.applyHeaders(req, jsonBody) for key, value := range requestHeaders { req.Header.Set(key, value) } a.applyProfileAuth(req) return req, nil } func (a *Adapter) mapProfileBodyModel(body []byte) ([]byte, error) { if a.profile == nil || len(a.profile.ModelMapping) == 0 || len(bytes.TrimSpace(body)) == 0 { return body, nil } trimmed := bytes.TrimSpace(body) if len(trimmed) < 2 || trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' { return nil, fmt.Errorf("decode profile request body: top-level JSON value must be an object") } var payload map[string]json.RawMessage if err := json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("decode profile request body: %w", err) } start, end, model, found, err := topLevelJSONStringField(body, "model") if err != nil { return nil, fmt.Errorf("decode profile request body: %w", err) } if !found { return body, nil } mapped := a.profile.MapModel(model) if mapped == model { return body, nil } encoded, err := json.Marshal(mapped) if err != nil { return nil, fmt.Errorf("encode profile request body: %w", err) } prepared := make([]byte, 0, len(body)-(end-start)+len(encoded)) prepared = append(prepared, body[:start]...) prepared = append(prepared, encoded...) prepared = append(prepared, body[end:]...) return prepared, nil } // topLevelJSONStringField locates the last top-level occurrence of key when // its value is a JSON string. It returns byte offsets into the original body, // allowing a surgical splice that preserves every unrelated byte. func topLevelJSONStringField(body []byte, wanted string) (start, end int, value string, found bool, err error) { pos := skipJSONSpace(body, 0) if pos >= len(body) || body[pos] != '{' { return 0, 0, "", false, fmt.Errorf("top-level JSON value must be an object") } pos++ for { pos = skipJSONSpace(body, pos) if pos >= len(body) { return 0, 0, "", false, fmt.Errorf("unterminated object") } if body[pos] == '}' { return start, end, value, found, nil } keyStart := pos keyEnd, scanErr := scanJSONStringEnd(body, keyStart) if scanErr != nil { return 0, 0, "", false, scanErr } var key string if unmarshalErr := json.Unmarshal(body[keyStart:keyEnd], &key); unmarshalErr != nil { return 0, 0, "", false, unmarshalErr } pos = skipJSONSpace(body, keyEnd) if pos >= len(body) || body[pos] != ':' { return 0, 0, "", false, fmt.Errorf("object key %q has no value", key) } valueStart := skipJSONSpace(body, pos+1) valueEnd, scanErr := scanJSONValueEnd(body, valueStart) if scanErr != nil { return 0, 0, "", false, scanErr } if key == wanted { found = false if valueStart < len(body) && body[valueStart] == '"' { var decoded string if unmarshalErr := json.Unmarshal(body[valueStart:valueEnd], &decoded); unmarshalErr != nil { return 0, 0, "", false, unmarshalErr } start, end, value, found = valueStart, valueEnd, decoded, true } } pos = skipJSONSpace(body, valueEnd) if pos < len(body) && body[pos] == ',' { pos++ continue } if pos < len(body) && body[pos] == '}' { return start, end, value, found, nil } return 0, 0, "", false, fmt.Errorf("invalid object delimiter") } } func skipJSONSpace(body []byte, pos int) int { for pos < len(body) { switch body[pos] { case ' ', '\t', '\r', '\n': pos++ default: return pos } } return pos } func scanJSONStringEnd(body []byte, start int) (int, error) { if start >= len(body) || body[start] != '"' { return 0, fmt.Errorf("expected JSON string") } escaped := false for pos := start + 1; pos < len(body); pos++ { if escaped { escaped = false continue } switch body[pos] { case '\\': escaped = true case '"': return pos + 1, nil } } return 0, fmt.Errorf("unterminated JSON string") } func scanJSONValueEnd(body []byte, start int) (int, error) { if start >= len(body) { return 0, fmt.Errorf("missing JSON value") } if body[start] == '"' { return scanJSONStringEnd(body, start) } depth := 0 inString := false escaped := false for pos := start; pos < len(body); pos++ { c := body[pos] if inString { if escaped { escaped = false continue } if c == '\\' { escaped = true } else if c == '"' { inString = false } continue } switch c { case '"': inString = true case '{', '[': depth++ case ']': depth-- case '}': if depth == 0 { return trimJSONSpaceEnd(body, start, pos), nil } depth-- case ',': if depth == 0 { return trimJSONSpaceEnd(body, start, pos), nil } } } return trimJSONSpaceEnd(body, start, len(body)), nil } func trimJSONSpaceEnd(body []byte, start, end int) int { for end > start { switch body[end-1] { case ' ', '\t', '\r', '\n': end-- default: return end } } return end } func (a *Adapter) applyProfileAuth(req *http.Request) { if a.profile == nil || strings.TrimSpace(a.profile.Auth.Header) == "" { return } target := http.CanonicalHeaderKey(a.profile.Auth.Header) value := strings.TrimSpace(req.Header.Get(target)) if value == "" { for _, source := range []string{"Authorization", "X-Api-Key"} { if candidate := strings.TrimSpace(req.Header.Get(source)); candidate != "" { value = candidate break } } } if value == "" { return } raw := value if fields := strings.Fields(value); len(fields) > 1 { raw = strings.Join(fields[1:], " ") } if scheme := strings.TrimSpace(a.profile.Auth.Scheme); scheme != "" { value = scheme + " " + raw } else { value = raw } for _, source := range []string{"Authorization", "X-Api-Key"} { if !strings.EqualFold(source, target) { req.Header.Del(source) } } req.Header.Set(target, value) } func (a *Adapter) doChatCompletion(ctx context.Context, body []byte) (*http.Response, error) { req, err := a.prepareRequest(ctx, http.MethodPost, string(config.OperationChatCompletions), "/v1/chat/completions", body, nil, true) if err != nil { return nil, err } return a.client.Do(req) } func (a *Adapter) buildRequestBody(model string, messages []chatMessage, input map[string]any) (map[string]any, error) { body := make(map[string]any) // Copy caller options as top-level OpenAI-compatible request fields first so // adapter-owned fields below always win over them. if opts, ok := input["options"].(map[string]any); ok { for k, v := range opts { body[k] = v } } // Pass through optional OpenAI-compatible fields when present in the input (except think). for _, key := range []string{"tools", "tool_choice", "format", "keep_alive"} { if v, ok := input[key]; ok { body[key] = v } } body["model"] = model body["messages"] = messages body["stream"] = true // Extract think-control fields hasThink := false var thinkVal bool if v, ok := input["think"]; ok { if bv, ok := v.(bool); ok { hasThink = true thinkVal = bv } } hasReasoningEffort := false var reasoningEffortVal string if v, ok := input["reasoning_effort"]; ok { if sv, ok := v.(string); ok { hasReasoningEffort = true reasoningEffortVal = sv } } hasBudget := false var budgetVal any if v, ok := input["thinking_token_budget"]; ok { hasBudget = true budgetVal = v } // vLLM, vLLM-MLX and Lemonade all serve Qwen3-family templates that honor // chat_template_kwargs.enable_thinking / thinking_token_budget. The Lemonade // build ignores the top-level `think` field, so all three providers map // think-control into chat_template_kwargs rather than top-level fields. switch a.provider { case "vllm", "vllm-mlx", "lemonade": thinkDisableRequested := (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none") // The vLLM-MLX runtime labels its entire streamed output as // reasoning_content and keeps generating reasoning even with // enable_thinking=false, so a think-disable request can never yield a // reasoning-free stream. Fail with a clear compatibility error instead // of silently returning a reasoning stream for think=false. if a.provider == "vllm-mlx" && thinkDisableRequested { return nil, fmt.Errorf("unsupported think control: provider %q cannot disable reasoning generation for streaming responses (think=false / reasoning_effort=none)", a.provider) } if hasReasoningEffort && reasoningEffortVal != "" && reasoningEffortVal != "none" { return nil, fmt.Errorf("unsupported think control: reasoning_effort %q is not supported by %s", reasoningEffortVal, a.provider) } if hasBudget { var budgetInt int64 valid := false switch bv := budgetVal.(type) { case int: budgetInt = int64(bv) valid = true case int64: budgetInt = bv valid = true case float64: if bv == float64(int64(bv)) { budgetInt = int64(bv) valid = true } } if !valid || budgetInt < 0 { return nil, fmt.Errorf("unsupported think control: invalid thinking_token_budget %v", budgetVal) } } if hasThink || (hasReasoningEffort && reasoningEffortVal == "none") || hasBudget { var chatTemplateKwargs map[string]any if ctk, ok := body["chat_template_kwargs"].(map[string]any); ok { chatTemplateKwargs = make(map[string]any) for k, v := range ctk { chatTemplateKwargs[k] = v } } else { chatTemplateKwargs = make(map[string]any) } if (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none") { chatTemplateKwargs["enable_thinking"] = false } else if (hasThink && thinkVal) || hasBudget { chatTemplateKwargs["enable_thinking"] = true } if hasBudget { chatTemplateKwargs["thinking_token_budget"] = budgetVal } body["chat_template_kwargs"] = chatTemplateKwargs } default: // empty/unknown provider: pass requested fields through as-is. if hasThink { body["think"] = thinkVal } if hasReasoningEffort { body["reasoning_effort"] = reasoningEffortVal } if hasBudget { body["thinking_token_budget"] = budgetVal } } return body, nil } func retryBodyWithForcedSingleTool(body map[string]any, errorBody string) (map[string]any, bool) { if !isAutoToolChoiceUnsupportedError(errorBody) { return nil, false } forced, ok := forcedToolChoiceForSingleTool(body["tools"]) if !ok { return nil, false } next := make(map[string]any, len(body)+1) for key, value := range body { next[key] = value } next["tool_choice"] = forced return next, true } func isAutoToolChoiceUnsupportedError(errorBody string) bool { errorBody = strings.ToLower(errorBody) return strings.Contains(errorBody, "auto") && strings.Contains(errorBody, "tool choice") && isToolCallingUnsupportedError(errorBody) } func isToolCallingUnsupportedError(errorBody string) bool { errorBody = strings.ToLower(errorBody) return strings.Contains(errorBody, "enable-auto-tool-choice") || strings.Contains(errorBody, "tool-call-parser") } func forcedToolChoiceForSingleTool(tools any) (map[string]any, bool) { items, ok := tools.([]any) if !ok || len(items) != 1 { return nil, false } tool, ok := items[0].(map[string]any) if !ok { return nil, false } name := "" if fn, ok := tool["function"].(map[string]any); ok { name, _ = fn["name"].(string) } if name == "" { name, _ = tool["name"].(string) } name = strings.TrimSpace(name) if name == "" { return nil, false } return map[string]any{ "type": "function", "function": map[string]any{ "name": name, }, }, true } func retryBodyWithTextToolFallback(body map[string]any, errorBody string) (map[string]any, bool) { if !isToolCallingUnsupportedError(errorBody) { return nil, false } instruction, ok := textToolFallbackInstruction(body["tools"]) if !ok { return nil, false } messages, ok := prependTextToolFallbackInstruction(body["messages"], instruction) if !ok { return nil, false } next := make(map[string]any, len(body)+1) for key, value := range body { next[key] = value } delete(next, "tools") delete(next, "tool_choice") next["messages"] = messages return next, true } func prependTextToolFallbackInstruction(value any, instruction string) (any, bool) { switch messages := value.(type) { case []chatMessage: system := chatMessage{Role: "system", Content: instruction} out := make([]chatMessage, 0, len(messages)+1) for _, msg := range messages { if strings.EqualFold(strings.TrimSpace(msg.Role), "system") { system.Content = joinTextToolFallbackSystemContent(system.Content, msg.Content) continue } out = append(out, msg) } return append([]chatMessage{system}, out...), true case []any: systemContent := instruction out := make([]any, 0, len(messages)+1) for _, item := range messages { msg, ok := item.(map[string]any) if !ok { out = append(out, item) continue } role, _ := msg["role"].(string) if strings.EqualFold(strings.TrimSpace(role), "system") { systemContent = joinTextToolFallbackSystemContent(systemContent, stringFromAny(msg["content"])) continue } out = append(out, item) } return append([]any{map[string]any{"role": "system", "content": systemContent}}, out...), true default: return nil, false } } func joinTextToolFallbackSystemContent(first, next string) string { first = strings.TrimSpace(first) next = strings.TrimSpace(next) switch { case first == "": return next case next == "": return first default: return first + "\n\n" + next } } func stringFromAny(value any) string { switch v := value.(type) { case nil: return "" case string: return v default: encoded, _ := json.Marshal(v) return string(encoded) } } func textToolFallbackInstruction(tools any) (string, bool) { items, ok := anyItems(tools) if !ok || len(items) == 0 { return "", false } encoded, err := json.Marshal(items) if err != nil { return "", false } return "Tool calls must be emitted as plain text because this backend does not support native OpenAI tool calling. When a tool is needed, respond with exactly one tool call and no markdown:\n" + "\n\nJSON_VALUE\n\n\n" + "run_commands executes from the client workspace root. Do not prepend cd to an absolute workspace path unless the user explicitly asks to operate in a different directory; prefer current-workspace commands such as git status.\n" + "Use valid JSON for each parameter value and follow the supplied parameter schema. Available tools JSON: " + string(encoded), true } func anyItems(value any) ([]any, bool) { switch v := value.(type) { case []any: return v, true default: encoded, err := json.Marshal(v) if err != nil { return nil, false } var out []any if err := json.Unmarshal(encoded, &out); err != nil { return nil, false } return out, true } } func completeEvent(runID, finishReason string, usage *runtime.UsageStats, outputTokens int, toolCalls []any, textToolFallback bool) runtime.RuntimeEvent { if usage == nil { usage = &runtime.UsageStats{OutputTokens: outputTokens} } var metadata map[string]string if finishReason != "" { metadata = map[string]string{"finish_reason": finishReason} } if textToolFallback { if metadata == nil { metadata = make(map[string]string, 2) } metadata[runtimeMetadataOpenAITextToolFallback] = "true" } if len(toolCalls) > 0 { if metadata == nil { metadata = make(map[string]string, 2) } if finishReason == "" { metadata["finish_reason"] = "tool_calls" } if encoded, err := json.Marshal(toolCalls); err == nil { metadata[runtimeMetadataOpenAIToolCalls] = string(encoded) } } return runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeComplete, Message: "openai_compat chat complete", Usage: usage, Metadata: metadata, Timestamp: time.Now(), } } // messagesFromInput extracts chat messages from the execution input. func messagesFromInput(input map[string]any) []chatMessage { if raw, ok := input["messages"].([]any); ok { out := make([]chatMessage, 0, len(raw)) for _, item := range raw { m, ok := item.(map[string]any) if !ok { continue } role, _ := m["role"].(string) content, _ := m["content"].(string) toolCallID, _ := m["tool_call_id"].(string) toolCalls := anySlice(m["tool_calls"]) role = strings.TrimSpace(role) content = strings.TrimSpace(content) toolCallID = strings.TrimSpace(toolCallID) if role == "" || (content == "" && len(toolCalls) == 0) { continue } out = append(out, chatMessage{ Role: role, Content: content, ToolCalls: toolCalls, ToolCallID: toolCallID, }) } if len(out) > 0 { return out } } if prompt := strings.TrimSpace(stringInput(input, "prompt")); prompt != "" { return []chatMessage{{Role: "user", Content: prompt}} } return nil } func emitError(ctx context.Context, sink runtime.EventSink, runID, msg string) error { return sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now(), }) } func stringInput(input map[string]any, key string) string { if input == nil { return "" } if v, ok := input[key].(string); ok { return v } return "" } func anySlice(v any) []any { if items, ok := v.([]any); ok { return items } return nil }