// Package types defines the unified (OpenAI-compatible) wire format that the // gateway exposes to its clients, plus the unified internal representation. package types import ( "bytes" "encoding/json" "errors" "strings" "time" ) // ErrBusy is the soft "source at capacity" sentinel shared by the provider // layer (returns it) and the scheduler layer (reacts to it): busy is not a // failure, so no cooldown/preference penalty is recorded, and gateways map // it to HTTP 429. Defined here so the scheduler does not depend on the // provider package (which pulls in the Lua runtime). var ErrBusy = errors.New("provider busy") // ---- in-bound request ---- // // ChatRequest now lives in maxwire.go as an alias of UnifiedRequest: the // kernel's internal format is the maximal set (OpenAI / Anthropic / Gemini / // Ollama / DeepSeek fields unioned), not "whatever an OpenAI client sent". The // type stays named ChatRequest at every call site for now — renaming 19 // non-test + 30 test references in one sweep would bury the semantic change in // mechanical noise, and an alias makes the migration free when it happens. // // The v1 struct that lived here is gone. Its fields and their fate: // // Model/Messages/Temperature/MaxTokens/Stream → UnifiedRequest, same tags // Tools/ToolChoice → json.RawMessage (raw passthrough // to the adapter; the kernel never // parses them) // DisableThinking (json:"disable_thinking") → REPLACED by Thinking. This was // the private field that leaked into // the body handed to adapters, forcing // 10 adapters to nil it out. // ExtraBody (hand-injected by MarshalJSON) → a real, declared field on // UnifiedRequest — no more // MarshalJSON back door // ClientSession (json:"-") → kept, still never serialized // // The custom MarshalJSON is gone with it: its only job was splicing ExtraBody // into an OpenAI-shaped body, which is no longer how the type serializes. // ChatMessage supports both plain string content and multimodal arrays // (RawMessage preserves whatever the client sent for the adapter to process). type ChatMessage struct { Role string `json:"role"` Content json.RawMessage `json:"content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"` } func StringContent(s string) json.RawMessage { b, _ := json.Marshal(s); return b } // isEmptyJSONArray reports whether raw is the literal empty JSON array []. func isEmptyJSONArray(raw json.RawMessage) bool { t := bytes.TrimSpace(raw) return len(t) == 2 && t[0] == '[' && t[1] == ']' } // UnmarshalJSON decodes a chat message, normalising an empty-array content // ("content":[]) to an empty string and dropping an empty tool_calls array. // // Why this exists: Lua adapters json.decode the request and re-encode it, and // an empty Lua table is indistinguishable from an empty JSON array — the // encoder emits {} for both. Agent clients serialise an assistant turn that // carries tool_calls and no text as content:[], so every pass-through adapter // turned it into content:{} — a shape that is not valid OpenAI (content is // string | array of parts | null) and that real upstreams reject with // "400 invalid arguments". Normalising at the decode boundary fixes every // adapter at once, including ones added later. func (m *ChatMessage) UnmarshalJSON(b []byte) error { type alias ChatMessage var a alias if err := json.Unmarshal(b, &a); err != nil { return err } *m = ChatMessage(a) if isEmptyJSONArray(m.Content) { m.Content = StringContent("") } if isEmptyJSONArray(m.ToolCalls) { m.ToolCalls = nil } return nil } type ToolCall struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Arguments map[string]interface{} `json:"arguments"` } // ---- Unified internal representation (what adapters produce) ---- type UnifiedResponse struct { Model string `json:"model,omitempty"` Content string `json:"content"` ReasoningContent string `json:"reasoning_content,omitempty"` FinishReason string `json:"finish_reason,omitempty"` TokenUsage TokenUsage `json:"token_usage"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ImageData used by image-generation adapters. ImageData []ImageData `json:"image_data,omitempty"` // Cost is the upstream-reported charge for this request, passed through // verbatim (OpenCode Zen/Go put a decimal **string** at the response top // level). Deliberately not parsed or summed by the gateway: it is an // upstream fact, and only some upstreams report it at all. Cost string `json:"cost,omitempty"` } type TokenUsage struct { Prompt int `json:"prompt"` Completion int `json:"completion"` Total int `json:"total"` // PromptTokensDetails mirrors the OpenAI v2 usage.prompt_tokens_details // object so cache-hit counts reported by OpenAI-compatible upstreams // (and by adapters that normalize their own cache fields into it) pass // through to clients that read it — dsh reads cached_tokens from here. PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"` // PromptCacheHit / PromptCacheMiss carry the DeepSeek-legacy standalone // fields; dsh falls back to prompt_cache_hit_tokens when // prompt_tokens_details.cached_tokens is absent. PromptCacheHit int `json:"prompt_cache_hit_tokens,omitempty"` PromptCacheMiss int `json:"prompt_cache_miss_tokens,omitempty"` // CompletionTokensDetails mirrors the OpenAI v2 usage.completion_tokens_details // object. OpenCode Zen/Go report how much of the completion was thinking // tokens here; without it clients cannot tell how much of the billed output // was reasoning rather than visible answer. CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` } // CompletionTokensDetails is the OpenAI v2 completion_tokens_details object. type CompletionTokensDetails struct { ReasoningTokens int `json:"reasoning_tokens"` } // PromptTokensDetails is the OpenAI v2 prompt_tokens_details object. Only // CachedTokens is emitted (omitempty drops the whole object when zero). type PromptTokensDetails struct { // CachedTokens is always emitted (even 0) so clients can distinguish // "upstream reports cache, this request missed" from "no cache data". CachedTokens int `json:"cached_tokens"` } // MarshalJSON emits both the legacy short keys (prompt/completion/total, used // by the internal unified representation and older clients) and the OpenAI // standard keys (prompt_tokens/completion_tokens/total_tokens). Standard // clients such as DSH and DevEco Code read the *_tokens fields. func (t TokenUsage) MarshalJSON() ([]byte, error) { // Auto-generate prompt_tokens_details from legacy DeepSeek fields when // the upstream adapter only set the standalone hit count (openai.lua // does this in Lua, but other adapters or the standardSSEChunk fallback // may not). dsh reads prompt_tokens_details.cached_tokens first. pdetails := t.PromptTokensDetails if pdetails == nil && t.PromptCacheHit > 0 { pdetails = &PromptTokensDetails{CachedTokens: t.PromptCacheHit} } return json.Marshal(struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` Prompt int `json:"prompt"` Completion int `json:"completion"` Total int `json:"total"` PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"` PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"` CompletionDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` }{ PromptTokens: t.Prompt, CompletionTokens: t.Completion, TotalTokens: t.Total, Prompt: t.Prompt, Completion: t.Completion, Total: t.Total, PromptTokensDetails: pdetails, PromptCacheHitTokens: t.PromptCacheHit, PromptCacheMissTokens: t.PromptCacheMiss, CompletionDetails: t.CompletionTokensDetails, }) } type ImageData struct { B64JSON string `json:"b64_json,omitempty"` URL string `json:"url,omitempty"` Revised string `json:"revised_prompt,omitempty"` } // ---- Image generation (OpenAI /v1/images/generations wire) ---- type ImageGenRequest struct { Model string `json:"model"` Prompt string `json:"prompt"` N int `json:"n,omitempty"` Size string `json:"size,omitempty"` ResponseFormat string `json:"response_format,omitempty"` } type ImageGenResponse struct { Created int64 `json:"created"` Data []ImageData `json:"data"` } // ---- Unified streaming chunk produced by adapters ---- // UnifiedChunk is one streamed delta. ToolCalls carries the raw upstream // streaming tool_calls array (incremental fragments with an index field), which // OpenAI-compatible clients accumulate themselves. type UnifiedChunk struct { Content string `json:"content"` Done bool `json:"done"` // FinishReason carries the upstream finish/stop reason ("tool_calls", // "length", ...) when the adapter provides it; the gateway emits it on // the terminating chunk instead of the default "stop". FinishReason string `json:"finish_reason,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` // Usage carries the upstream token usage when the stream chunk provides // it (OpenAI-style streams attach usage to some chunks; the final one // often has empty choices). Gateway uses it to emit exact usage in the // final stream chunk instead of estimates. Usage *TokenUsage `json:"usage,omitempty"` // Cost is upstream-reported charge for this request (decimal string), // passed through verbatim; OpenCode sends it on its own stream chunk. Cost string `json:"cost,omitempty"` } // Meta passed to Lua build_headers hook type BuildMeta struct { URL string `json:"url"` Method string `json:"method"` Body string `json:"body"` APIKey string `json:"api_key"` Timestamp int64 `json:"timestamp"` Source map[string]interface{} `json:"source"` } func Now() int64 { return time.Now().Unix() } // OneLine flattens an error string to a single line capped at n chars. // Shared by the provider layer (upstream error reasons) and the gateway // layer (client-facing AUTO-chain summaries). func OneLine(s string, n int) string { s = strings.Join(strings.Fields(s), " ") if len(s) > n { s = s[:n] + "..." } return s }