Files
yetone 40c5b92a93 gateway: structured output crosses every translation: a Responses text.format or a Chat response_format reaches a Chat, Responses, Messages or Gemini upstream in its own words (frostming on Discord: responses.parse through magpie to OpenRouter came back as plain text)
The IR held a schema only from Anthropic's output_config.format, and only
Messages (and Claude Code's --json-schema) were asked for one. A Responses
request to a provider magpie speaks Chat or Messages to (OpenRouter has no
Responses URL) lost text.format; a Chat request translated to Responses or
Messages lost response_format; an Anthropic schema translated to Chat or
Responses was lost too.

Format (format.go) now carries json_schema (name, description, schema,
strict) or json_object from Responses, Chat, Anthropic and Gemini, and each
builder asks for it in its upstream's field. Where a target can't say it
(json_object on Messages or Claude Code; the vendor bridges Cursor, Devin,
Kiro, Qoder, Command Code, Zed) it is told in the system prompt. An upstream
answering 400/422 naming the field (DeepSeek's "This response_format type is
unavailable now") is asked again with it told in words, and so from then on.
Relayed requests are unchanged.
2026-10-08 10:37:02 +08:00

151 lines
5.1 KiB
Go

package gateway
import (
"encoding/json"
"regexp"
)
// Format is the shape a client asked the answer to come in (structured
// output), read from whichever API it spoke: Responses' text.format,
// Chat's response_format, Anthropic's output_config.format, Gemini's
// generationConfig.responseMimeType with its schema. Each API it is built
// for again asks for it in its own words; one that can't is told it in
// the system prompt (inSystem).
type Format struct {
// Type is "json_schema" (Schema holds the schema the answer fits) or
// "json_object" (any JSON object).
Type string
// Name and Description are json_schema's, as OpenAI's APIs take them;
// Name is "response" where the client's API has none.
Name, Description string
Schema json.RawMessage
// Strict is the client's strict, nil when it said none.
Strict *bool
}
// formatName is the name a schema goes under when the client's API names
// none (Anthropic's, Gemini's): OpenAI requires one.
const formatName = "response"
// openAIFormat reads the format OpenAI's APIs ask for, in Responses'
// text.format ({type, name, schema, strict, description}) or Chat's
// response_format ({type, json_schema: {name, schema, strict,
// description}}). nil for {type: "text"}, the default, and for anything
// else.
func openAIFormat(raw json.RawMessage) *Format {
var f struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
Strict *bool `json:"strict"`
JSONSchema *struct {
Name string `json:"name"`
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
Strict *bool `json:"strict"`
} `json:"json_schema"`
}
if json.Unmarshal(raw, &f) != nil {
return nil
}
switch f.Type {
case "json_object":
return &Format{Type: "json_object"}
case "json_schema":
if js := f.JSONSchema; js != nil {
f.Name, f.Description, f.Schema, f.Strict = js.Name, js.Description, js.Schema, js.Strict
}
if len(sentRaw(f.Schema)) == 0 {
// a schema of no shape is any JSON object
return &Format{Type: "json_object"}
}
if f.Name == "" {
f.Name = formatName
}
return &Format{Type: "json_schema", Name: f.Name, Description: f.Description, Schema: f.Schema, Strict: f.Strict}
}
return nil
}
// schema is the JSON schema the answer has to fit, nil when there is none
// (no format, or any JSON object).
func (f *Format) schema() json.RawMessage {
if f == nil || f.Type != "json_schema" {
return nil
}
return f.Schema
}
// responses is the format as Responses' text.format says it.
func (f *Format) responses() map[string]any {
if f.Type != "json_schema" {
return map[string]any{"type": "json_object"}
}
out := map[string]any{"type": "json_schema", "name": f.Name, "schema": f.Schema}
if f.Description != "" {
out["description"] = f.Description
}
if f.Strict != nil {
out["strict"] = *f.Strict
}
return out
}
// chat is the format as Chat's response_format says it.
func (f *Format) chat() map[string]any {
if f.Type != "json_schema" {
return map[string]any{"type": "json_object"}
}
js := map[string]any{"name": f.Name, "schema": f.Schema}
if f.Description != "" {
js["description"] = f.Description
}
if f.Strict != nil {
js["strict"] = *f.Strict
}
return map[string]any{"type": "json_schema", "json_schema": js}
}
// instruction is the format said in words, for a model whose API can't be
// asked for it: Anthropic's Messages for any JSON object (its
// output_config.format takes a schema only), a bridge to a vendor's own
// API, or an upstream that turned the field away (formatRefused).
func (f *Format) instruction() string {
if s := f.schema(); len(s) > 0 {
return "Respond with a single JSON value and nothing else, matching this JSON schema:\n" + string(s) + "."
}
return "Respond with a single JSON object and nothing else."
}
// inSystem is r with its format told in the system prompt instead of
// asked for in a field, r itself when it has none.
func (r *Request) inSystem() *Request {
if r.Format == nil {
return r
}
q := *r
q.Format = nil
if q.System != "" {
q.System += "\n\n"
}
q.System += r.Format.instruction()
return &q
}
// formatRefused is how unfit remembers a provider turning away the format
// field for a model (a relay or model without structured output: DeepSeek's
// "This response_format type is unavailable now"), so it is told the
// format in the system prompt from then on.
func formatRefused(model string) string { return "response_format:" + model }
// formatRefusal is an upstream's 400 naming the field structured output is
// asked for in, in any API's words.
var formatRefusal = regexp.MustCompile(`(?i)response_format|json_schema|json_object|output_config\.format|text\.format|structured.output|response_?mime_?type|response_?json_?schema|response_?schema`)
// refusesFormat is whether an upstream's answer turned the request away
// over its format.
func refusesFormat(status int, body []byte) bool {
return (status == 400 || status == 422) && formatRefusal.Match(body)
}