mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
The loop no longer classifies a tool-call turn that arrived without reasoning, retries it, or keeps a file-backed cooldown for it. Removes the watch and the cooldown state, the deferred stream sink they needed, the provider policy interfaces and their implementations, the descriptor field on the extension wire (schema and SDK regenerated), the state-dir path, the run-metrics counters and the protocol-recovery kinds. The protocol-recovery audit channel itself stays for now.
1226 lines
44 KiB
Go
1226 lines
44 KiB
Go
// Package openai implements the OpenAI-compatible /chat/completions provider.
|
|
// It self-registers under the "openai" kind, so DeepSeek, MiMo, MiniMax-M3, and
|
|
// any other OpenAI-compatible endpoint are just config instances rather than
|
|
// code. Each instance picks the wire shape from its base URL:
|
|
// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus
|
|
// reasoning_effort as a depth hint.
|
|
// - api.minimaxi.com / api.minimax.io → M3 exposes binary thinking;
|
|
// M3.1 Flash always thinks and accepts reasoning_effort depth.
|
|
// - open.bigmodel.cn / api.z.ai or the glm protocol (Zhipu GLM) → emits
|
|
// thinking.type; the documented depth models also accept reasoning_effort
|
|
// (provider.ZhipuEffortContract). Older GLM keeps thinking.type only.
|
|
// - api.longcat.chat → emits thinking.type=enabled|disabled and omits
|
|
// reasoning_effort, matching LongCat's OpenAI-compatible API.
|
|
// - ollama.com → accepts hosted Ollama Cloud's reasoning_effort scale,
|
|
// including max, and omits the field for none/disabled.
|
|
// - Kimi K3 preserves complete messages and uses max_completion_tokens.
|
|
// - everything else (MiMo and other OpenAI-compatible gateways) uses the
|
|
// vanilla reasoning_effort scale (low/medium/high), unless its config
|
|
// declares a custom supported_efforts validation contract.
|
|
//
|
|
// See docs/REASONING_PROVIDERS.md for the per-backend protocol reference.
|
|
package openai
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"reasonix/internal/base/netclient"
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/safety/endpointclient"
|
|
)
|
|
|
|
// defaultStreamIdleTimeout caps how long a started SSE stream may go without any
|
|
// bytes before it's treated as a dropped connection. A half-open TCP connection
|
|
// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block
|
|
// forever; this turns that hang into a recoverable error. Generous on purpose —
|
|
// live streams emit tokens/keepalives far more often. Stored per-client
|
|
// (client.idleTimeout) so a test can shorten it without a shared global that
|
|
// would race other streams' watchdogs.
|
|
const defaultStreamIdleTimeout = provider.StreamIdleTimeout
|
|
|
|
// maxPrefixContinuations keeps automatic recovery bounded. A second length
|
|
// finish is surfaced through the existing truncation notice instead of opening
|
|
// an unbounded (and billable) continuation loop against the Beta endpoint.
|
|
const maxPrefixContinuations = 1
|
|
|
|
func init() {
|
|
provider.Register("openai", New)
|
|
}
|
|
|
|
// New builds an OpenAI-compatible provider from a resolved config.
|
|
func New(cfg provider.Config) (provider.Provider, error) {
|
|
if cfg.BaseURL == "" {
|
|
return nil, fmt.Errorf("openai: base_url is required for provider %q", cfg.Name)
|
|
}
|
|
if cfg.Model == "" {
|
|
return nil, fmt.Errorf("openai: model is required for provider %q", cfg.Name)
|
|
}
|
|
name := cfg.Name
|
|
if name == "" {
|
|
name = "openai"
|
|
}
|
|
keyEnv, _ := cfg.Extra["api_key_env"].(string) // for actionable auth errors
|
|
keySource, _ := cfg.Extra["api_key_source"].(string)
|
|
effort, _ := cfg.Extra["effort"].(string)
|
|
effort = strings.ToLower(strings.TrimSpace(effort))
|
|
if effort == "auto" {
|
|
effort = ""
|
|
}
|
|
protocol, _ := cfg.Extra["reasoning_protocol"].(string)
|
|
protocol = normalizeReasoningProtocol(protocol)
|
|
kimiK3 := usesKimiK3Contract(protocol, cfg.BaseURL, cfg.Model)
|
|
supportedEfforts, _ := cfg.Extra["supported_efforts"].([]string)
|
|
// A meaningful explicit list is the endpoint's declared effort vocabulary;
|
|
// auto remains implicit and is therefore ignored here.
|
|
supportedEfforts, hasExplicitEfforts := reasoningEffortVocabulary(kimiK3, supportedEfforts)
|
|
chatURL := resolveChatURL(cfg.BaseURL, cfg.Extra)
|
|
prefixChatURL := deepSeekPrefixChatURL(chatURL)
|
|
headers, _ := cfg.Extra["headers"].(map[string]string)
|
|
extraBody, _ := cfg.Extra["extra_body"].(map[string]any)
|
|
vision, _ := cfg.Extra["vision"].(bool)
|
|
officialDeepSeek := IsDeepSeek(cfg.BaseURL)
|
|
// DeepSeek's official chat API takes image parts only on the models that
|
|
// declare them, and the parts need no new serializer: DeepSeek documents the
|
|
// OpenAI image_url shape verbatim. Keep the guard here anyway — no persisted
|
|
// or extension-supplied capability flag may put pixels on a wire that will
|
|
// reject them, whatever config resolution decided upstream.
|
|
vision = vision && visionReachesModel(officialDeepSeek, cfg.Model)
|
|
videoProtocol, _ := cfg.Extra["video_protocol"].(string)
|
|
visionDetail, _ := cfg.Extra["vision_detail"].(string)
|
|
visionDetail = strings.ToLower(strings.TrimSpace(visionDetail))
|
|
if !detailAccepted(visionDetail, officialDeepSeek) {
|
|
visionDetail = "" // auto — omit the field
|
|
}
|
|
deepseek := protocol == "deepseek" || (protocol == "" && officialDeepSeek)
|
|
maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int)
|
|
deepseekV4Flash := strings.EqualFold(strings.TrimSpace(cfg.Model), "deepseek-v4-flash")
|
|
minimax := protocol == "" && IsMiniMax(cfg.BaseURL)
|
|
zhipu := protocol == "glm" || (protocol == "" && IsZhipu(cfg.BaseURL))
|
|
zhipuDepth := ""
|
|
if zhipu {
|
|
zhipuDepth = provider.ZhipuDepthModel(cfg.Model)
|
|
}
|
|
longcat := protocol == "" && IsLongCat(cfg.BaseURL)
|
|
ollamaCloud := protocol == "" && IsOllamaCloud(cfg.BaseURL)
|
|
thinkingType := configuredThinkingType(cfg)
|
|
switch {
|
|
case protocol == "none":
|
|
effort = ""
|
|
case deepseek:
|
|
if thinkingType == "disabled" {
|
|
effort = ""
|
|
break
|
|
}
|
|
switch effort {
|
|
case "", "off": // "off" is a retired level (disabled thinking); fall back to the default depth
|
|
effort = "high"
|
|
case "disabled":
|
|
if hasExplicitEfforts && !supportsEffort(supportedEfforts, effort) {
|
|
return nil, fmt.Errorf("openai: provider %q: effort %q is not listed in supported_efforts: %v: %w", name, effort, supportedEfforts, provider.ErrEffortRefused)
|
|
}
|
|
// DeepSeek can turn thinking off too; route through thinking.type and
|
|
// drop the depth hint so the wire carries thinking.type=disabled only.
|
|
effort = ""
|
|
thinkingType = "disabled"
|
|
default:
|
|
if hasExplicitEfforts {
|
|
// A provider that declares supported_efforts defines the endpoint's
|
|
// complete effort vocabulary. Honor that list for compatible DeepSeek
|
|
// request shapes instead of applying the built-in official scale.
|
|
if !supportsEffort(supportedEfforts, effort) {
|
|
return nil, fmt.Errorf("openai: provider %q: effort %q is not listed in supported_efforts: %v: %w", name, effort, supportedEfforts, provider.ErrEffortRefused)
|
|
}
|
|
break
|
|
}
|
|
switch effort {
|
|
case "low":
|
|
if !deepseekV4Flash {
|
|
return nil, fmt.Errorf("openai: provider %q uses DeepSeek thinking; effort low requires deepseek-v4-flash or explicit supported_efforts", name)
|
|
}
|
|
case "high", "max":
|
|
default:
|
|
return nil, fmt.Errorf("openai: provider %q uses DeepSeek thinking; effort must be low, high, max, or disabled", name)
|
|
}
|
|
}
|
|
case minimax:
|
|
var err error
|
|
effort, err = resolveMiniMaxEffort(cfg, effort)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case zhipu:
|
|
resolved, err := resolveZhipuEffort(name, zhipuDepth, effort)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
effort = resolved
|
|
case longcat:
|
|
// LongCat exposes a binary thinking knob on its OpenAI-compatible endpoint:
|
|
// thinking.type=enabled|disabled. It documents reasoning text via
|
|
// reasoning_content, but not the generic reasoning_effort scale.
|
|
switch effort {
|
|
case "", "enabled", "disabled":
|
|
default:
|
|
return nil, fmt.Errorf("openai: provider %q uses LongCat thinking; effort must be enabled or disabled", name)
|
|
}
|
|
case ollamaCloud:
|
|
// Hosted Ollama Cloud uses top-level reasoning_effort. "none" and the
|
|
// legacy/off aliases intentionally omit the field, which lets the model
|
|
// run without thinking. Local Ollama is not auto-detected because its
|
|
// model/version support varies.
|
|
switch effort {
|
|
case "", "none", "disabled", "off":
|
|
effort = ""
|
|
case "xhigh", "max":
|
|
effort = "max"
|
|
case "low", "medium", "high":
|
|
default:
|
|
return nil, fmt.Errorf("openai: provider %q uses Ollama Cloud thinking; effort must be none, low, medium, high, or max", name)
|
|
}
|
|
case effort != "":
|
|
if hasExplicitEfforts {
|
|
// Explicit endpoint metadata overrides the generic OpenAI enum and its
|
|
// legacy max-to-high compatibility clamp.
|
|
if !supportsEffort(supportedEfforts, effort) {
|
|
return nil, fmt.Errorf("openai: provider %q: effort %q is not listed in supported_efforts: %v: %w", name, effort, supportedEfforts, provider.ErrEffortRefused)
|
|
}
|
|
break
|
|
}
|
|
// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/
|
|
// high) by default. Without an explicit provider vocabulary, max remains
|
|
// clamped to the OpenAI ceiling because MiMo and similar backends reject it.
|
|
switch effort {
|
|
case "max":
|
|
effort = "high"
|
|
case "low", "medium", "high":
|
|
default:
|
|
return nil, fmt.Errorf("openai: provider %q: effort must be low, medium, or high: %w", name, provider.ErrEffortRefused)
|
|
}
|
|
}
|
|
requestEfforts := requestEffortVocabulary(effortEndpoint{protocol: protocol,
|
|
thinkingType: thinkingType, effort: effort, deepseek: deepseek, flash: deepseekV4Flash,
|
|
minimax: minimax, zhipu: zhipu, longcat: longcat, ollamaCloud: ollamaCloud,
|
|
zhipuDepth: zhipuDepth,
|
|
explicit: hasExplicitEfforts, supported: supportedEfforts})
|
|
if minimax && provider.IsMiniMaxFlash(cfg.Model) {
|
|
requestEfforts = provider.MiniMaxFlashEfforts()
|
|
}
|
|
// max_output_tokens=0 means automatic (not unlimited). DeepSeek reasoning
|
|
// uses 32K / high-max 64K; thinking-disabled stays ordinary 16K. 128K is
|
|
// never automatic — users must set it explicitly after length truncations.
|
|
// This budget never participates in compact_ratio.
|
|
autoMaxOutput := maxOutputTokens == 0 && officialDeepSeek
|
|
if autoMaxOutput {
|
|
reasoningOn := thinkingType != "disabled" && effort != "disabled" && effort != "off" && effort != "none"
|
|
maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort)
|
|
}
|
|
httpClient, err := newHTTPClient(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openai: network: %w", err)
|
|
}
|
|
return &client{
|
|
name: name,
|
|
apiKey: cfg.APIKeyResolver(),
|
|
keyEnv: keyEnv,
|
|
keySource: keySource,
|
|
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
|
chatURL: chatURL,
|
|
learned: endpointFacts{stream: streamFactsFor(chatURL)},
|
|
prefixChatURL: prefixChatURL,
|
|
headers: cleanCustomHeaders(headers),
|
|
extraBody: cleanExtraBody(extraBody),
|
|
model: normalizeModelID(cfg.BaseURL, cfg.Model),
|
|
deepseek: deepseek,
|
|
minimax: minimax,
|
|
zhipu: zhipu,
|
|
zhipuDepth: zhipuDepth,
|
|
longcat: longcat,
|
|
kimiK3: kimiK3,
|
|
draft202012Schemas: IsMiMo(cfg.BaseURL),
|
|
thinkingType: thinkingType,
|
|
vision: vision,
|
|
video: newVideoWire(videoProtocol, cfg.BaseURL),
|
|
visionDetail: visionDetail,
|
|
maxOutputTokens: maxOutputTokens,
|
|
autoMaxOutput: autoMaxOutput,
|
|
effort: effort,
|
|
requestEfforts: requestEfforts,
|
|
http: httpClient,
|
|
idleTimeout: provider.IdleTimeoutFromExtra(cfg.Extra),
|
|
openCodeSession: provider.NewOpenCodeSessionID(),
|
|
}, nil
|
|
}
|
|
|
|
func newHTTPClient(cfg provider.Config) (*http.Client, error) {
|
|
spec, _ := cfg.Extra["proxy_spec"].(netclient.ProxySpec)
|
|
return endpointclient.New(spec, netclient.TransportOptions{
|
|
DialTimeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
TLSHandshakeTimeout: 15 * time.Second,
|
|
ResponseHeaderTimeout: provider.IdleTimeoutFromExtra(cfg.Extra),
|
|
})
|
|
}
|
|
|
|
type client struct {
|
|
name string
|
|
apiKey func() string
|
|
keyEnv string // api_key_env name, surfaced in auth errors
|
|
keySource string // source of keyEnv, surfaced in auth errors
|
|
baseURL string
|
|
chatURL string
|
|
prefixChatURL string // official DeepSeek Beta endpoint; empty for custom gateways
|
|
headers map[string]string
|
|
extraBody map[string]any
|
|
model string
|
|
http *http.Client
|
|
deepseek bool
|
|
minimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort
|
|
zhipu bool // true for Zhipu GLM (bigmodel.cn / z.ai)
|
|
zhipuDepth string // documented direct-API reasoning_effort model, if any
|
|
longcat bool // true for LongCat — gates thinking via thinking.type, ignores reasoning_effort
|
|
kimiK3 bool // true for the explicit K3 protocol or kimi-k3 on Moonshot's direct API hosts
|
|
draft202012Schemas bool // the endpoint's dialect, not its vendor: rewrite pre-2020-12 tuple keywords
|
|
thinkingType string // explicit `thinking` config override (enabled|disabled); "" = no override
|
|
video *videoWire // how a video file reaches this model; nil when the endpoint declares none
|
|
vision bool // model accepts image input — embed attached images as image_url parts
|
|
visionDetail string // image_url detail hint (low|high); "" = auto/omit
|
|
maxOutputTokens int // resolved total output budget; <=0 omits the optional field
|
|
autoMaxOutput bool // true when max_output_tokens=0 (automatic ladder)
|
|
effort string // reasoning_effort for OpenAI; thinking.type for MiniMax; "" = auto/provider default
|
|
requestEfforts []string // depth levels a per-request EffortOverride may take; empty = overrides ignored
|
|
idleTimeout time.Duration // SSE stall watchdog window; per-provider idle_timeout_seconds, else defaultStreamIdleTimeout
|
|
openCodeSession string // x-opencode-session this client presents to OpenCode Go
|
|
learned endpointFacts
|
|
}
|
|
|
|
func (c *client) Name() string { return c.name }
|
|
|
|
func (c *client) RequiresToolCallReasoning() bool {
|
|
return c != nil && c.deepseek && c.thinkingType != "disabled"
|
|
}
|
|
|
|
func (c *client) RequiresReasoningRoundTrip() bool {
|
|
return c != nil && (c.minimax || c.kimiK3 || c.glmThinkingEnabled())
|
|
}
|
|
|
|
func (c *client) sendOpts() provider.SendOptions {
|
|
return provider.SendOptions{
|
|
Provider: c.name,
|
|
KeyEnv: c.keyEnv,
|
|
KeySource: c.keySource,
|
|
KeyPresent: c.apiKey() != "",
|
|
RetryAuth: c.learned.authed.Load(),
|
|
HeaderTimeout: c.idleTimeout,
|
|
}
|
|
}
|
|
|
|
func normalizeReasoningProtocol(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "deepseek", "glm", "kimi-k3", "openai", "none":
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func cleanCustomHeaders(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for rawName, rawValue := range in {
|
|
name := strings.TrimSpace(rawName)
|
|
value := strings.TrimSpace(rawValue)
|
|
if name == "" || value == "" || reservedCustomHeader(name) {
|
|
continue
|
|
}
|
|
out[name] = value
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyCustomHeaders(h http.Header, headers map[string]string) {
|
|
for name, value := range cleanCustomHeaders(headers) {
|
|
h.Set(name, value)
|
|
}
|
|
}
|
|
|
|
func applyAPIKeyHeader(h http.Header, baseURL, apiKey string) {
|
|
apiKey = strings.TrimSpace(apiKey)
|
|
if apiKey == "" {
|
|
return
|
|
}
|
|
if IsMiMo(baseURL) {
|
|
h.Set("api-key", apiKey)
|
|
return
|
|
}
|
|
h.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
|
|
func cleanExtraBody(in map[string]any) map[string]any {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]any, len(in))
|
|
for rawName, value := range in {
|
|
name := strings.TrimSpace(rawName)
|
|
if name == "" || reservedExtraBodyField(name) {
|
|
continue
|
|
}
|
|
out[name] = value
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func reservedExtraBodyField(name string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "model", "messages", "tools", "stream", "stream_options", "temperature", "max_tokens", "max_completion_tokens", "max_output_tokens", "reasoning_effort", "thinking":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func reservedCustomHeader(name string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "authorization", "content-type", "accept", "host":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn
|
|
// allocates a buffer, marshals the request, and sends it — pooling avoids the
|
|
// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is
|
|
// provider-level (not global) so OpenAI and Anthropic don't compete.
|
|
var bufPool = sync.Pool{
|
|
New: func() any { return new(bytes.Buffer) },
|
|
}
|
|
|
|
func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
|
stream, err := c.openChatStream(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.prefixChatURL == "" {
|
|
return stream, nil
|
|
}
|
|
|
|
out := make(chan provider.Chunk)
|
|
go c.streamWithPrefixContinuation(ctx, req, stream, out)
|
|
return out, nil
|
|
}
|
|
|
|
func (c *client) openStream(ctx context.Context, targetURL string, wireReq chatRequest, tools []provider.ToolSchema) (<-chan provider.Chunk, error) {
|
|
requestCtx := provider.WithRequestAttemptCounter(ctx)
|
|
buf := bufPool.Get().(*bytes.Buffer)
|
|
buf.Reset()
|
|
if err := json.NewEncoder(buf).Encode(wireReq); err != nil {
|
|
bufPool.Put(buf)
|
|
return nil, fmt.Errorf("%s: marshal request: %w", c.name, err)
|
|
}
|
|
body := make([]byte, buf.Len())
|
|
copy(body, buf.Bytes())
|
|
bufPool.Put(buf)
|
|
|
|
newReq := func(ctx context.Context) (*http.Request, error) {
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.setChatHeaders(httpReq)
|
|
return httpReq, nil
|
|
}
|
|
resp, err := provider.SendWithRetry(requestCtx, c.http, c.sendOpts(), newReq)
|
|
if err != nil {
|
|
return nil, provider.AnnotateToolSchemaError(err, tools)
|
|
}
|
|
c.learned.authed.Store(true)
|
|
|
|
out := make(chan provider.Chunk)
|
|
// Body-phase stream cuts surface as StreamInterruptedError so the Agent
|
|
// can replay the exact frozen request. Connection+header retries stay in
|
|
// SendWithRetry; providers must not stack a second body-retry budget.
|
|
go c.streamOnce(requestCtx, resp, out)
|
|
return out, nil
|
|
}
|
|
|
|
// streamWithPrefixContinuation makes a DeepSeek Beta continuation look like one
|
|
// ordinary provider stream. Text/reasoning stays live, while usage is folded
|
|
// across both requests so cost and cache accounting remain truthful. If the
|
|
// Beta request fails before emitting anything, the original truncated response
|
|
// is kept and its finish_reason=length reaches the agent's existing warning.
|
|
func (c *client) streamWithPrefixContinuation(ctx context.Context, req provider.Request, current <-chan provider.Chunk, out chan<- provider.Chunk) {
|
|
defer close(out)
|
|
|
|
var fullText, fullReasoning strings.Builder
|
|
var totalUsage *provider.Usage
|
|
continuations := 0
|
|
|
|
for {
|
|
var currentUsage *provider.Usage
|
|
currentHadTool := false
|
|
currentEmitted := false
|
|
|
|
for chunk := range current {
|
|
switch chunk.Type {
|
|
case provider.ChunkText:
|
|
fullText.WriteString(chunk.Text)
|
|
currentEmitted = currentEmitted || chunk.Text != ""
|
|
if !sendChunk(ctx, out, chunk) {
|
|
return
|
|
}
|
|
case provider.ChunkReasoning:
|
|
fullReasoning.WriteString(chunk.Text)
|
|
currentEmitted = currentEmitted || chunk.Text != ""
|
|
if !sendChunk(ctx, out, chunk) {
|
|
return
|
|
}
|
|
case provider.ChunkToolCallStart, provider.ChunkToolCallArgsDelta, provider.ChunkToolCall:
|
|
currentHadTool = true
|
|
currentEmitted = true
|
|
if !sendChunk(ctx, out, chunk) {
|
|
return
|
|
}
|
|
case provider.ChunkUsage:
|
|
currentUsage = mergeUsage(currentUsage, chunk.Usage, false)
|
|
case provider.ChunkDone:
|
|
// The wrapper emits one final Done after any continuation.
|
|
case provider.ChunkError:
|
|
// A Beta failure before any continuation bytes is a safe fallback:
|
|
// the already-streamed first response remains visible and its
|
|
// length finish reason triggers the normal truncation warning.
|
|
if continuations > 0 && !currentEmitted && ctx.Err() == nil {
|
|
emitUsageAndDone(ctx, out, totalUsage)
|
|
return
|
|
}
|
|
_ = sendChunk(ctx, out, chunk)
|
|
return
|
|
default:
|
|
if !sendChunk(ctx, out, chunk) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
totalUsage = mergeUsage(totalUsage, currentUsage, true)
|
|
if continuations >= maxPrefixContinuations ||
|
|
currentUsage == nil || currentUsage.FinishReason != "length" ||
|
|
currentHadTool ||
|
|
(fullText.Len() == 0 && (c.thinkingType == "disabled" || fullReasoning.Len() == 0)) {
|
|
emitUsageAndDone(ctx, out, totalUsage)
|
|
return
|
|
}
|
|
|
|
prefixReq := c.buildPrefixRequest(req, fullText.String(), fullReasoning.String())
|
|
next, err := c.openStream(ctx, c.prefixChatURL, prefixReq, req.Tools)
|
|
if err != nil {
|
|
emitUsageAndDone(ctx, out, totalUsage)
|
|
return
|
|
}
|
|
continuations++
|
|
current = next
|
|
}
|
|
}
|
|
|
|
func emitUsageAndDone(ctx context.Context, out chan<- provider.Chunk, usage *provider.Usage) {
|
|
if usage != nil && !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkUsage, Usage: usage}) {
|
|
return
|
|
}
|
|
_ = sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkDone})
|
|
}
|
|
|
|
// mergeUsage folds token counters. countRequests is false for multiple usage
|
|
// chunks from one HTTP stream (keep its request count), and true when combining
|
|
// distinct prefix-continuation requests (sum their request counts).
|
|
func mergeUsage(total, next *provider.Usage, countRequests bool) *provider.Usage {
|
|
if next == nil {
|
|
return total
|
|
}
|
|
if total == nil {
|
|
clone := *next
|
|
return &clone
|
|
}
|
|
totalRequests := usageRequestCount(total)
|
|
nextRequests := usageRequestCount(next)
|
|
total.PromptTokens += next.PromptTokens
|
|
total.CompletionTokens += next.CompletionTokens
|
|
total.TotalTokens += next.TotalTokens
|
|
total.CacheHitTokens += next.CacheHitTokens
|
|
total.CacheMissTokens += next.CacheMissTokens
|
|
total.CacheWriteTokens += next.CacheWriteTokens
|
|
total.CacheWriteBilledTokens += next.CacheWriteBilledTokens
|
|
total.ReasoningTokens += next.ReasoningTokens
|
|
if countRequests {
|
|
total.RequestCount = totalRequests + nextRequests
|
|
} else if nextRequests > totalRequests {
|
|
total.RequestCount = nextRequests
|
|
} else {
|
|
total.RequestCount = totalRequests
|
|
}
|
|
total.FinishReason = next.FinishReason
|
|
return total
|
|
}
|
|
|
|
func usageRequestCount(usage *provider.Usage) int {
|
|
if usage != nil && usage.RequestCount > 0 {
|
|
return usage.RequestCount
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// streamOnce drives a single body read. Mid-stream transport cuts become
|
|
// StreamInterruptedError so the Agent can commit-or-replay; providers no longer
|
|
// replay the body themselves (that would stack retry budgets with the Agent).
|
|
func (c *client) streamOnce(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {
|
|
defer close(out)
|
|
emitted, err := c.readStream(ctx, resp, out)
|
|
if err = streamOutcome(c.name, emitted, err); err == nil {
|
|
return
|
|
}
|
|
sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkError, Err: streamFailure(emitted, err)})
|
|
}
|
|
|
|
func sendChunk(ctx context.Context, out chan<- provider.Chunk, chunk provider.Chunk) bool {
|
|
select {
|
|
case out <- chunk:
|
|
return true
|
|
default:
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case out <- chunk:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (c *client) buildRequest(req provider.Request) chatRequest {
|
|
// Repair tool-call pairing before sending: an interrupted/resumed history can
|
|
// carry an assistant tool_calls turn whose results never landed, which DeepSeek
|
|
// rejects with a 400 ("must be followed by tool messages …").
|
|
src := provider.SanitizeToolPairing(req.Messages)
|
|
msgs := make([]chatMessage, 0, len(src))
|
|
// Images returned by tool calls can't ride in the tool message itself — the
|
|
// OpenAI API accepts only text content parts under role "tool" — so they are
|
|
// carried by a synthetic user message injected after the turn's full run of
|
|
// tool results, before the next non-tool message (splitting a tool-result
|
|
// run would break the API's tool-call pairing validation).
|
|
var pending toolMedia
|
|
openCodeGo := provider.IsOpenCodeGoEndpoint(c.chatURL)
|
|
flushToolImages := func() { msgs = pending.flush(msgs, c.visionDetail) }
|
|
for _, m := range src {
|
|
if m.Role != provider.RoleTool {
|
|
flushToolImages()
|
|
}
|
|
cm := chatMessage{
|
|
Role: string(m.Role),
|
|
ToolCallID: m.ToolCallID,
|
|
}
|
|
if m.Role == provider.RoleTool && !openCodeGo {
|
|
// Strict backends (MiMo) 400 a tool result without the key, even
|
|
// empty (#4711); OpenCode Go 400s one that carries it.
|
|
name := m.Name
|
|
cm.Name = &name
|
|
}
|
|
cm.ReasoningContent = c.toolCallReasoning(m)
|
|
for _, tc := range m.ToolCalls {
|
|
wire := chatToolCall{ID: tc.ID, Type: "function"}
|
|
wire.Function.Name = tc.Name
|
|
wire.Function.Arguments = tc.Arguments
|
|
if tc.ThoughtSignature != "" && usesGeminiThoughtSignatures(c.baseURL, c.model) {
|
|
// Gemini's current OpenAI compatibility schema carries the
|
|
// opaque signature beside the function payload. Keep the
|
|
// legacy function.thought_signature field decode-only below so
|
|
// older gateways remain readable without sending an unknown
|
|
// function parameter to current Google endpoints.
|
|
wire.ExtraContent = &chatToolCallExtraContent{}
|
|
wire.ExtraContent.Google.ThoughtSignature = tc.ThoughtSignature
|
|
}
|
|
cm.ToolCalls = append(cm.ToolCalls, wire)
|
|
}
|
|
switch {
|
|
case c.vision && m.Role == provider.RoleUser && len(m.Images) > 0:
|
|
cm.Content = imageContentParts(m.Content, m.Images, c.visionDetail)
|
|
case m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != "":
|
|
cm.Content = m.Content
|
|
}
|
|
msgs = append(msgs, cm)
|
|
if m.Role == provider.RoleTool {
|
|
pending.add(m, c.vision)
|
|
}
|
|
}
|
|
flushToolImages()
|
|
|
|
var tools []chatTool
|
|
for _, t := range req.Tools {
|
|
parameters := t.Parameters
|
|
if len(parameters) == 0 {
|
|
parameters = provider.CanonicalizeSchema(nil)
|
|
}
|
|
if c.draft202012Schemas {
|
|
parameters = provider.NormalizeLegacyTupleItemsForDraft202012(parameters)
|
|
}
|
|
tools = append(tools, chatTool{
|
|
Type: "function",
|
|
Function: chatFunction{Name: t.Name, Description: t.Description, Parameters: parameters},
|
|
})
|
|
}
|
|
|
|
maxOutputTokens := req.MaxTokens
|
|
if maxOutputTokens == 0 {
|
|
if c.autoMaxOutput && c.deepseek {
|
|
// Re-resolve so per-request EffortOverride (high/max) can raise 32K→64K.
|
|
effort := c.requestEffort(req)
|
|
reasoningOn := c.thinkingType != "disabled" &&
|
|
effort != "disabled" && effort != "off" && effort != "none"
|
|
maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort)
|
|
} else {
|
|
maxOutputTokens = c.maxOutputTokens
|
|
}
|
|
}
|
|
if maxOutputTokens < 0 {
|
|
maxOutputTokens = 0
|
|
}
|
|
out := chatRequest{
|
|
Model: c.model,
|
|
Messages: msgs,
|
|
Tools: tools,
|
|
Stream: true,
|
|
StreamOptions: c.streamOptions(),
|
|
Temperature: req.Temperature,
|
|
MaxTokens: maxOutputTokens,
|
|
ReasoningEffort: kimiK3ReasoningEffort(c.kimiK3, c.requestEffort(req)),
|
|
ExtraBody: c.extraBody,
|
|
}
|
|
switch {
|
|
case c.kimiK3:
|
|
// K3 fixes its sampling values and recommends omitting them. It also
|
|
// names the output budget max_completion_tokens rather than max_tokens.
|
|
out.Temperature = nil
|
|
out.MaxTokens = 0
|
|
out.MaxCompletionTokens = maxOutputTokens
|
|
out.ExtraBody = omitExtraBodyFields(out.ExtraBody,
|
|
"temperature", "top_p", "n", "presence_penalty", "frequency_penalty", "max_completion_tokens")
|
|
case IsOpenAI(c.baseURL):
|
|
// OpenAI's current Chat Completions contract replaces max_tokens with
|
|
// max_completion_tokens, which includes visible and reasoning tokens and
|
|
// is required by o-series models. Compatible gateways retain max_tokens.
|
|
out.MaxTokens = 0
|
|
out.MaxCompletionTokens = maxOutputTokens
|
|
case c.deepseek:
|
|
// DeepSeek's CoT is controlled by `thinking` plus `reasoning_effort` for
|
|
// depth. Thinking is on by default but can be turned off via
|
|
// effort=disabled / thinking=disabled (credit @eghrhegpe, #5063).
|
|
if c.thinkingType == "disabled" {
|
|
out.Thinking = &thinkingMode{Type: "disabled"}
|
|
} else {
|
|
out.Thinking = &thinkingMode{Type: "enabled"}
|
|
}
|
|
case c.minimax:
|
|
c.applyMiniMaxThinking(&out, req)
|
|
case c.zhipu:
|
|
c.applyZhipuEffort(&out, req)
|
|
case c.longcat:
|
|
// LongCat's binary thinking knob: "enabled" (default, thinking on) or
|
|
// "disabled". The API documents reasoning_content in OpenAI responses but
|
|
// not reasoning_effort, so keep depth out of the request.
|
|
t := c.effort
|
|
if t == "" {
|
|
t = c.thinkingType
|
|
}
|
|
if t == "" {
|
|
t = "enabled"
|
|
}
|
|
out.Thinking = &thinkingMode{Type: t}
|
|
out.ReasoningEffort = ""
|
|
case c.thinkingType != "":
|
|
// Generic OpenAI-compatible provider with an explicit `thinking` config
|
|
// field (e.g. opencode.ai) — emit thinking.type; reasoning_effort, if any,
|
|
// is left untouched for backends that also honour it.
|
|
out.Thinking = &thinkingMode{Type: c.thinkingType}
|
|
}
|
|
return out.forSummary(req.Summary)
|
|
}
|
|
|
|
func (c *client) buildPrefixRequest(req provider.Request, content, reasoning string) chatRequest {
|
|
out := c.buildRequest(req)
|
|
prefix := chatMessage{Role: "assistant", Content: content, Prefix: true}
|
|
if c.deepseek && c.thinkingType != "disabled" {
|
|
prefix.ReasoningContent = &reasoning
|
|
}
|
|
out.Messages = append(out.Messages, prefix)
|
|
return out
|
|
}
|
|
|
|
// readStream parses one SSE response into chunks: text deltas stream live,
|
|
// tool-call fragments accumulate by index and emit complete on [DONE], and a
|
|
// ChunkToolCallStart fires the moment a call's name is known. It returns whether
|
|
// any model output was forwarded (so the caller can decide a replay is safe) and
|
|
// the first fatal error — a nil error means the stream reached [DONE].
|
|
func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {
|
|
defer resp.Body.Close()
|
|
|
|
// Close the response body when the context is canceled (user interrupt) or the
|
|
// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of
|
|
// hanging on a half-open connection. done lets the watchdog exit on a normal
|
|
// return — otherwise it outlives the call and blocks forever on a non-cancellable
|
|
// context whose Done() is nil. The watchdog owns the timer; the read loop only
|
|
// pings the buffered activity channel, so there's no Timer.Reset race.
|
|
idleTimeout := c.idleTimeout
|
|
if idleTimeout <= 0 { // zero-value client (constructed without New)
|
|
idleTimeout = defaultStreamIdleTimeout
|
|
}
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
activity := make(chan struct{}, 1)
|
|
var stalled atomic.Bool
|
|
go func() {
|
|
idle := time.NewTimer(idleTimeout)
|
|
defer idle.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
resp.Body.Close()
|
|
return
|
|
case <-idle.C:
|
|
stalled.Store(true)
|
|
resp.Body.Close()
|
|
return
|
|
case <-activity:
|
|
if !idle.Stop() {
|
|
select {
|
|
case <-idle.C:
|
|
default:
|
|
}
|
|
}
|
|
idle.Reset(idleTimeout)
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
acc := map[int]*provider.ToolCall{}
|
|
started := map[int]bool{}
|
|
argBucket := map[int]int{}
|
|
var order []int
|
|
var lastFinishReason string
|
|
var sawDone bool
|
|
var think thinkSplitter
|
|
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
select { // ping the idle watchdog; non-blocking so a full buffer is fine
|
|
case activity <- struct{}{}:
|
|
default:
|
|
}
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
if data == "[DONE]" {
|
|
sawDone = true
|
|
break
|
|
}
|
|
if data == "" {
|
|
continue
|
|
}
|
|
|
|
var sr streamResponse
|
|
if err := json.Unmarshal([]byte(data), &sr); err != nil {
|
|
return emitted, provider.StreamDecodeError(c.name, data, err)
|
|
}
|
|
if sr.Error != nil {
|
|
return emitted, &provider.StreamPayloadError{Provider: c.name, Message: sr.Error.Message, Code: sr.Error.Code.String(), Type: sr.Error.Type, Param: sr.Error.Param}
|
|
}
|
|
if len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != "" {
|
|
lastFinishReason = *sr.Choices[0].FinishReason
|
|
}
|
|
if sr.Usage != nil {
|
|
u := normaliseUsage(sr.Usage)
|
|
u.FinishReason = lastFinishReason
|
|
provider.ApplyRequestAttemptCount(ctx, u)
|
|
emitted = true
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkUsage, Usage: u}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
if len(sr.Choices) == 0 {
|
|
continue
|
|
}
|
|
|
|
delta := sr.Choices[0].Delta
|
|
reasoningDelta := delta.ReasoningContent
|
|
if reasoningDelta == "" {
|
|
reasoningDelta = delta.Reasoning
|
|
}
|
|
if reasoningDelta != "" {
|
|
emitted = true
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, Text: reasoningDelta}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
if delta.Content != "" {
|
|
r, txt := think.push(delta.Content)
|
|
if r != "" {
|
|
emitted = true
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, Text: r}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
if txt != "" {
|
|
emitted = true
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkText, Text: txt}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
for _, tc := range delta.ToolCalls {
|
|
cur, ok := acc[tc.Index]
|
|
if !ok {
|
|
cur = &provider.ToolCall{}
|
|
acc[tc.Index] = cur
|
|
order = append(order, tc.Index)
|
|
}
|
|
if tc.ID != "" {
|
|
cur.ID = tc.ID
|
|
}
|
|
if tc.Function.Name != "" {
|
|
cur.Name = tc.Function.Name
|
|
}
|
|
cur.Arguments += tc.Function.Arguments
|
|
thoughtSignature := ""
|
|
if tc.ExtraContent != nil {
|
|
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
|
|
}
|
|
if thoughtSignature == "" {
|
|
// Early Gemini OpenAI-compatible responses placed the field in
|
|
// function. Accept that shape when replaying older sessions and
|
|
// when talking to compatibility gateways that still emit it.
|
|
thoughtSignature = tc.Function.ThoughtSignature
|
|
}
|
|
if thoughtSignature != "" {
|
|
cur.ThoughtSignature = thoughtSignature
|
|
}
|
|
// Signal the call's start the moment its name is known, so a frontend
|
|
// can show the tool card immediately rather than only after its
|
|
// (possibly large) arguments finish streaming.
|
|
if !started[tc.Index] && cur.Name != "" {
|
|
started[tc.Index] = true
|
|
emitted = true
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
// Progress ticks while a large argument payload streams (a 30KB
|
|
// write_file body can take a minute-plus): one chunk per 2KB bucket
|
|
// so the consumer can show liveness without per-delta spam.
|
|
if started[tc.Index] {
|
|
if bucket := len(cur.Arguments) / 2048; bucket > argBucket[tc.Index] {
|
|
argBucket[tc.Index] = bucket
|
|
emitted = true
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCallArgsDelta, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}, ArgChars: len(cur.Arguments)}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return emitted, err
|
|
}
|
|
if stalled.Load() {
|
|
// A body-phase cut, so the Agent can replay the frozen request.
|
|
// Providers no longer reconnect here.
|
|
return emitted, stallCut(c.name, idleTimeout)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return emitted, provider.ReadCut(c.name, err)
|
|
}
|
|
// A proxy that idle-closes with a clean FIN ends the scan with no error. Without
|
|
// this check the turn would be committed as complete — including half-streamed
|
|
// tool-call arguments, which then 400 on every replay (#3953). OpenAI Chat
|
|
// accepts either [DONE] or a legal finish_reason as a complete terminal.
|
|
if !sawDone && lastFinishReason == "" {
|
|
return emitted, prematureCut(c.name)
|
|
}
|
|
|
|
if r, txt := think.flush(); r != "" || txt != "" {
|
|
if r != "" {
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, Text: r}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
if txt != "" {
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkText, Text: txt}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
slices.Sort(order)
|
|
for _, idx := range order {
|
|
tc := acc[idx]
|
|
if tc.ID == "" {
|
|
// Some OpenAI-compatible gateways stream tool calls by index with no id.
|
|
// Synthesize a stable one so the result can be paired back to its call —
|
|
// an empty tool_call_id collapses multi-tool turns downstream.
|
|
tc.ID = fmt.Sprintf("call_%d", idx)
|
|
}
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
}
|
|
if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkDone}) {
|
|
return emitted, ctx.Err()
|
|
}
|
|
return emitted, nil
|
|
}
|
|
|
|
// normaliseUsage folds the cache shapes used by OpenAI-compatible providers into
|
|
// a single Usage. DeepSeek reports prompt_cache_{hit,miss}_tokens at the top of
|
|
// usage; OpenAI and MiMo put cache hits under prompt_tokens_details; some
|
|
// compatible gateways return Anthropic-style input/cache counters instead.
|
|
// Reasoning tokens land in completion_tokens_details on thinking-mode models.
|
|
func normaliseUsage(u *wireUsage) *provider.Usage {
|
|
prompt := u.PromptTokens
|
|
anthropicPrompt := prompt == 0 &&
|
|
(u.InputTokens != 0 || u.CacheCreationInputTokens != 0 || u.CacheReadInputTokens != 0)
|
|
if anthropicPrompt {
|
|
// Anthropic-style input_tokens excludes both cache reads and cache
|
|
// writes, while Reasonix PromptTokens represents the complete input.
|
|
prompt = u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens
|
|
}
|
|
completion := u.CompletionTokens
|
|
if completion == 0 {
|
|
completion = u.OutputTokens
|
|
}
|
|
total := u.TotalTokens
|
|
if total == 0 && (prompt != 0 || completion != 0) {
|
|
total = prompt + completion
|
|
}
|
|
|
|
hit := u.PromptCacheHitTokens
|
|
miss := u.PromptCacheMissTokens
|
|
if hit == 0 && u.PromptTokensDetails != nil {
|
|
hit = u.PromptTokensDetails.CachedTokens
|
|
}
|
|
if hit == 0 {
|
|
hit = u.CacheReadInputTokens
|
|
}
|
|
if prompt > 0 && hit > prompt {
|
|
// prompt_tokens includes cached tokens, so a bigger hit is a broken report, not a perfect cache.
|
|
hit = prompt
|
|
}
|
|
if miss == 0 {
|
|
switch {
|
|
case anthropicPrompt:
|
|
// Cache writes are still uncached input for Reasonix pricing and
|
|
// cache-ratio accounting.
|
|
miss = u.InputTokens + u.CacheCreationInputTokens
|
|
case u.PromptTokensDetails != nil:
|
|
// A details object is the endpoint reporting the split: cached=0
|
|
// is an explicit full miss, not missing cache accounting.
|
|
miss = max(prompt-hit, 0)
|
|
case hit > 0 && prompt > hit:
|
|
miss = prompt - hit
|
|
}
|
|
}
|
|
reasoning := 0
|
|
if u.CompletionTokensDetails != nil {
|
|
reasoning = u.CompletionTokensDetails.ReasoningTokens
|
|
}
|
|
return &provider.Usage{
|
|
PromptTokens: prompt,
|
|
CompletionTokens: completion,
|
|
TotalTokens: total,
|
|
CacheHitTokens: hit,
|
|
CacheMissTokens: miss,
|
|
ReasoningTokens: reasoning,
|
|
}
|
|
}
|
|
|
|
// OpenAI-compatible wire protocol
|
|
|
|
func omitExtraBodyFields(in map[string]any, names ...string) map[string]any {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
omit := make(map[string]struct{}, len(names))
|
|
for _, name := range names {
|
|
omit[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
|
|
}
|
|
out := make(map[string]any, len(in))
|
|
for name, value := range in {
|
|
if _, blocked := omit[strings.ToLower(strings.TrimSpace(name))]; !blocked {
|
|
out[name] = value
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r chatRequest) MarshalJSON() ([]byte, error) {
|
|
type wire chatRequest
|
|
baseReq := wire(r)
|
|
baseReq.ExtraBody = nil
|
|
raw, err := json.Marshal(baseReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(r.ExtraBody) == 0 {
|
|
return raw, nil
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
maps.Copy(body, cleanExtraBody(r.ExtraBody))
|
|
return json.Marshal(body)
|
|
}
|
|
|
|
type thinkingMode struct {
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type streamOptions struct {
|
|
IncludeUsage bool `json:"include_usage"`
|
|
}
|
|
|
|
type chatMessage struct {
|
|
Role string `json:"role"`
|
|
// content is always present (never omitted): DeepSeek's strict deserializer
|
|
// rejects a message missing the field. A pure tool_calls assistant turn
|
|
// serializes as null (nil here); a string for every other text message
|
|
// (empty included — null is rejected by some backends for a tool message);
|
|
// and a []chatContentPart array for a vision user turn carrying images.
|
|
Content any `json:"content"`
|
|
// Prefix is wire-only and is set exclusively on an automatically recovered
|
|
// DeepSeek assistant tail. omitempty keeps every ordinary request byte-stable.
|
|
Prefix bool `json:"prefix,omitempty"`
|
|
// A pointer so the field can serialize as an empty string: DeepSeek thinking
|
|
// mode requires the reasoning_content key to be PRESENT on assistant
|
|
// tool_calls turns (an empty value passes; a missing key 400s), while every
|
|
// other message must keep omitting it.
|
|
ReasoningContent *string `json:"reasoning_content,omitempty"`
|
|
ToolCalls []chatToolCall `json:"tool_calls,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
// Name is the role=tool message's function name. A pointer so ordinary
|
|
// messages omit the key (byte-stable prefix), while tool messages keep it
|
|
// even empty for strict backends (MiMo); OpenCode Go rejects the key.
|
|
Name *string `json:"name,omitempty"`
|
|
}
|
|
|
|
type chatContentPart struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ImageURL *chatImageURL `json:"image_url,omitempty"`
|
|
VideoURL *chatVideoURL `json:"video_url,omitempty"`
|
|
}
|
|
|
|
type chatImageURL struct {
|
|
URL string `json:"url"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
func imageContentParts(text string, images []string, detail string) []chatContentPart {
|
|
parts := make([]chatContentPart, 0, len(images)+1)
|
|
if text != "" {
|
|
parts = append(parts, chatContentPart{Type: "text", Text: text})
|
|
}
|
|
for _, url := range images {
|
|
parts = append(parts, chatContentPart{Type: "image_url", ImageURL: &chatImageURL{URL: url, Detail: detail}})
|
|
}
|
|
return parts
|
|
}
|
|
|
|
type chatTool struct {
|
|
Type string `json:"type"`
|
|
Function chatFunction `json:"function"`
|
|
}
|
|
|
|
type chatFunction struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
|
}
|
|
|
|
type chatToolCall struct {
|
|
Index int `json:"index,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
ExtraContent *chatToolCallExtraContent `json:"extra_content,omitempty"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
// Decode compatibility for the early Gemini OpenAI shape. New requests
|
|
// use extra_content.google.thought_signature.
|
|
ThoughtSignature string `json:"thought_signature,omitempty"`
|
|
} `json:"function"`
|
|
}
|
|
|
|
type chatToolCallExtraContent struct {
|
|
Google struct {
|
|
ThoughtSignature string `json:"thought_signature,omitempty"`
|
|
} `json:"google"`
|
|
}
|
|
|
|
type streamResponse struct {
|
|
Choices []struct {
|
|
Delta struct {
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
Reasoning string `json:"reasoning"`
|
|
ToolCalls []chatToolCall `json:"tool_calls"`
|
|
} `json:"delta"`
|
|
FinishReason *string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Usage *wireUsage `json:"usage"`
|
|
Error *streamWireError `json:"error"`
|
|
}
|
|
|
|
// wireUsage covers DeepSeek's top-level cache fields, OpenAI/MiMo's nested
|
|
// details, and Anthropic-style fallbacks returned by compatible gateways.
|
|
type wireUsage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
|
|
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
|
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
|
PromptTokensDetails *struct {
|
|
CachedTokens int `json:"cached_tokens"`
|
|
} `json:"prompt_tokens_details"`
|
|
CompletionTokensDetails *struct {
|
|
ReasoningTokens int `json:"reasoning_tokens"`
|
|
} `json:"completion_tokens_details"`
|
|
}
|