| 1 | package responses |
| 2 | |
| 3 | import ( |
| 4 | "net/url" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/provider" |
| 8 | ) |
| 9 | |
| 10 | // vendorCapabilities describes how a Responses-compatible endpoint deviates |
| 11 | // from the base OpenAI Responses wire behavior. Vendors are detected from the |
| 12 | // base URL (DetectVendor); unknown endpoints get the zero value, which is the |
| 13 | // standard OpenAI-compatible behavior (stateful, no session-cache header, no |
| 14 | // summary requirement, no tool-call reasoning retention, temperature honored). |
| 15 | // |
| 16 | // This table is the single source of truth for wire-level vendor differences: |
| 17 | // adding a new Responses-compatible vendor means adding one entry here and a |
| 18 | // base-URL case in DetectVendor — not threading more string comparisons |
| 19 | // through responses.go. |
| 20 | type vendorCapabilities struct { |
| 21 | // stateless marks endpoints that reject previous_response_id and require |
| 22 | // the full input history on every turn (DeepSeek, MiMo). stateful is the |
| 23 | // OpenAI default. |
| 24 | stateless bool |
| 25 | |
| 26 | // sessionCacheHeader marks DashScope, whose session cache must be opted |
| 27 | // into with the x-dashscope-session-cache header. |
| 28 | sessionCacheHeader bool |
| 29 | |
| 30 | // toolCallReasoning marks stateless vendors whose documentation requires |
| 31 | // retaining historical reasoning content in the input on multi-turn tool |
| 32 | // calls (DeepSeek, MiMo). |
| 33 | toolCallReasoning bool |
| 34 | |
| 35 | // singleSegmentReasoning marks endpoints whose thinking is one |
| 36 | // uninterruptible segment per turn: the server emits reasoning and the |
| 37 | // final answer atomically, and a new reasoning segment only starts on a |
| 38 | // brand-new turn — never mid-turn after a tool call. MiMo documents this |
| 39 | // ("reasoning.effort: low/medium/high all enable reasoning, no strength |
| 40 | // differentiation"; tool-call turns carry one segment). DeepSeek, by |
| 41 | // contrast, can emit several reasoning segments across a turn's tool |
| 42 | // loop. Callers must not expect a multi-segment chain-of-thought from |
| 43 | // single-segment vendors. |
| 44 | singleSegmentReasoning bool |
| 45 | |
| 46 | // ignoresTemperature marks vendors that force temperature/top_p to their |
| 47 | // defaults in thinking mode, so sending them is a no-op (MiMo forces |
| 48 | // 1.0 / 0.95). Keeps the wire request lean for such endpoints. |
| 49 | ignoresTemperature bool |
| 50 | |
| 51 | // defaultMaxOutputTokens is the max_output_tokens sent when the caller |
| 52 | // did not request one (req.MaxTokens == 0). Zero means "leave unset and |
| 53 | // let the server use its own default". MiMo's server default (32768) |
| 54 | // covers reasoning + visible output, and its thinking mode can spend a |
| 55 | // large chunk of that budget on reasoning before the visible answer — |
| 56 | // truncating tool calls mid-JSON on long turns. Raise it to the next |
| 57 | // documented tier (65536, within the allowed [1, 131072] range) so the |
| 58 | // answer survives long reasoning. |
| 59 | defaultMaxOutputTokens int |
| 60 | |
| 61 | // summaryRequired marks vendors whose Responses API requires the |
| 62 | // `summary` list on input reasoning items (DashScope; without it the |
| 63 | // server rejects with "Invalid 'summary': summary is required..."). The |
| 64 | // OpenAI base format only needs `content`. Sending `summary` to vendors |
| 65 | // that do not define it (MiMo) leaks the reasoning text into an extra |
| 66 | // field the server may fold back into the model context, doubling the |
| 67 | // chain-of-thought echoed each turn and inflating reasoning output |
| 68 | // until truncation. Only send it where the wire demands it. |
| 69 | summaryRequired bool |
| 70 | } |
| 71 | |
| 72 | var vendorTable = map[string]vendorCapabilities{ |
| 73 | "dashscope": { |
| 74 | stateless: false, |
| 75 | sessionCacheHeader: true, |
| 76 | toolCallReasoning: false, |
| 77 | singleSegmentReasoning: false, |
| 78 | ignoresTemperature: false, |
| 79 | summaryRequired: true, |
| 80 | }, |
| 81 | "deepseek": { |
| 82 | stateless: true, |
| 83 | sessionCacheHeader: false, |
| 84 | toolCallReasoning: true, |
| 85 | singleSegmentReasoning: false, |
| 86 | ignoresTemperature: false, |
| 87 | defaultMaxOutputTokens: provider.DefaultReasoningOutputTokens, |
| 88 | }, |
| 89 | "mimo": { |
| 90 | stateless: true, |
| 91 | sessionCacheHeader: false, |
| 92 | toolCallReasoning: true, |
| 93 | singleSegmentReasoning: true, |
| 94 | ignoresTemperature: true, |
| 95 | defaultMaxOutputTokens: 65536, |
| 96 | }, |
| 97 | // "" (unknown OpenAI-compatible endpoint) → zero value = default behavior. |
| 98 | } |
| 99 | |
| 100 | // capabilitiesFor returns the wire capabilities for a detected vendor name. |
| 101 | // Unknown vendors fall back to the zero value (standard OpenAI behavior). |
| 102 | func capabilitiesFor(vendor string) vendorCapabilities { |
| 103 | return vendorTable[vendor] |
| 104 | } |
| 105 | |
| 106 | // DetectVendor identifies endpoint behavior that affects the Responses wire. |
| 107 | // Empty means an unknown OpenAI-compatible endpoint with default behavior. |
| 108 | func DetectVendor(baseURL string) string { |
| 109 | u, err := url.Parse(strings.TrimSpace(baseURL)) |
| 110 | if err != nil { |
| 111 | return "" |
| 112 | } |
| 113 | host := strings.ToLower(u.Hostname()) |
| 114 | switch { |
| 115 | case host == "dashscope.aliyuncs.com", strings.HasSuffix(host, ".dashscope.aliyuncs.com"), strings.HasSuffix(host, ".maas.aliyuncs.com"): |
| 116 | return "dashscope" |
| 117 | case host == "api.deepseek.com", strings.HasSuffix(host, ".deepseek.com"): |
| 118 | return "deepseek" |
| 119 | case host == "api.xiaomimimo.com", strings.HasSuffix(host, ".xiaomimimo.com"): |
| 120 | return "mimo" |
| 121 | default: |
| 122 | return "" |
| 123 | } |
| 124 | } |
| 125 |