diff --git a/agent/provider.go b/agent/provider.go index f14a37d..ba12d5e 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -132,6 +132,12 @@ func FromEnv() (Config, bool) { if c.Model == "" { c.Model = "gpt-4o" } + case "openai-responses": + c.Format = llm.FormatOpenAIResponses + c.APIKey = oaiKey + if c.Model == "" { + c.Model = "gpt-5" + } default: c.Format = llm.FormatAnthropic c.APIKey = anthKey @@ -165,6 +171,13 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config { if c.Model == "" { c.Model = "gpt-4o" } + case "openai-responses": + c.Format = llm.FormatOpenAIResponses + // provider appends "/responses"; tolerate a full endpoint URL. + c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/responses"), "/") + if c.Model == "" { + c.Model = "gpt-5" + } default: c.Format = llm.FormatAnthropic // provider appends "/v1/messages". @@ -186,10 +199,13 @@ func isFalsy(s string) bool { return false } -// Provider returns the short provider name ("anthropic"/"openai"). +// Provider returns the short provider name ("anthropic"/"openai"/"openai-responses"). func (c Config) Provider() string { - if c.Format == llm.FormatOpenAI { + switch c.Format { + case llm.FormatOpenAI: return "openai" + case llm.FormatOpenAIResponses: + return "openai-responses" } return "anthropic" } diff --git a/agent/provider_responses_test.go b/agent/provider_responses_test.go new file mode 100644 index 0000000..fea982a --- /dev/null +++ b/agent/provider_responses_test.go @@ -0,0 +1,30 @@ +package agent + +import ( + "testing" + + "github.com/Autumn-27/norma/llm" +) + +// TestConfigFromOpenAIResponses locks the openai-responses format wiring: +// ConfigFrom resolves the Responses format, strips a full /responses endpoint +// back to the API base, and Provider() round-trips the short name. NewProvider +// must build a working provider for it. +func TestConfigFromOpenAIResponses(t *testing.T) { + c := ConfigFrom("openai-responses", "gpt-5", "https://gw.example/v1/responses", "sk-x", "") + if c.Format != llm.FormatOpenAIResponses { + t.Fatalf("format=%v, want FormatOpenAIResponses", c.Format) + } + if c.BaseURL != "https://gw.example/v1" { + t.Fatalf("base_url=%q, want the /responses suffix stripped", c.BaseURL) + } + if c.Provider() != "openai-responses" { + t.Fatalf("Provider()=%q", c.Provider()) + } + if !c.Stream { // default streaming preserved + t.Fatal("Stream should default true") + } + if _, err := c.NewProvider(); err != nil { + t.Fatalf("NewProvider: %v", err) + } +} diff --git a/db/schema.sql b/db/schema.sql index 8875d83..6a863f2 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -284,7 +284,7 @@ CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS llm_profiles ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, - format TEXT NOT NULL CHECK (format IN ('openai','anthropic')), + format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')), base_url TEXT, proxy TEXT, model TEXT NOT NULL, @@ -317,6 +317,11 @@ ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false; -- 流式开关;补旧库。默认 true = 保持既有的流式行为,旧配置无感升级。 ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true; +-- 放开 format 约束以容纳 openai-responses(OpenAI Responses API);补旧库。 +-- 每次启动执行,幂等:先删旧 CHECK 再建含三值的新 CHECK。 +ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check; +ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check + CHECK (format IN ('openai','anthropic','openai-responses')); -- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。 -- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填, diff --git a/go.mod b/go.mod index 481403a..e79c704 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Autumn-27/artex go 1.26.3 require ( - github.com/Autumn-27/norma v0.3.0 + github.com/Autumn-27/norma v0.3.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.10.0 github.com/lqqyt2423/go-mitmproxy v1.9.2 diff --git a/go.sum b/go.sum index 06acf18..1ba35d1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Autumn-27/norma v0.3.0 h1:nbaoeLj65ElFwWdy44U0QCXPe0idaw8tdyyuU/Sdbuw= -github.com/Autumn-27/norma v0.3.0/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw= +github.com/Autumn-27/norma v0.3.1 h1:cSRojFJjivm4l3ck/KJXym7VyCnM1GLY7iq1pRYk4kk= +github.com/Autumn-27/norma v0.3.1/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= diff --git a/server/server.go b/server/server.go index 461b4a4..303ef72 100644 --- a/server/server.go +++ b/server/server.go @@ -317,10 +317,9 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) { // saveLLMConfig persists the LLM config as the active "default" profile in PG. func (s *Server) saveLLMConfig(cfg agent.Config) error { + // cfg.Provider() already returns one of the three valid format strings + // (anthropic / openai / openai-responses), matching the DB CHECK constraint. format := cfg.Provider() - if format != "anthropic" { - format = "openai" - } var id int64 // agent.Config carries no failover fields, so carry the stored ones forward — // otherwise this legacy endpoint would silently reset the profile's priority, diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 02baba2..8eced23 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -1926,11 +1926,13 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { var candidates []candidate switch provider { - case "openai": + case "openai", "openai-responses": + // Responses API shares the OpenAI model list at /v1/models; tolerate a full + // endpoint URL of either format. if baseURL == "" { baseURL = "https://api.openai.com/v1" } - b := strings.TrimRight(strings.TrimSuffix(baseURL, "/chat/completions"), "/") + b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/") candidates = append(candidates, candidate{b + "/models", bearerHdr()}) default: // anthropic if baseURL == "" { diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index db51f48..3a3e5af 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -296,7 +296,7 @@ function ProfileSheet({ }) { const isNew = !profile; const [name, setName] = React.useState(""); - const [format, setFormat] = React.useState<"anthropic" | "openai">("anthropic"); + const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic"); const [model, setModel] = React.useState(""); const [baseUrl, setBaseUrl] = React.useState(""); const [proxy, setProxy] = React.useState(""); @@ -321,7 +321,9 @@ function ProfileSheet({ React.useEffect(() => { if (!open) return; setName(profile?.name ?? ""); - setFormat(profile?.format === "openai" ? "openai" : "anthropic"); + setFormat( + profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic", + ); setModel(profile?.model ?? ""); setBaseUrl(profile?.base_url ?? ""); setProxy(profile?.proxy ?? ""); @@ -448,13 +450,14 @@ function ProfileSheet({