From 27201fd99770d28da99a29c100d6ba85f6191624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20=C5=BDuni=C4=8D?= <36313686+gregpr07@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:20:25 -0700 Subject: [PATCH] feat(provider): support GPT-6 Astra --- README.md | 10 ++ packages/core/src/models-dev.ts | 38 +++++- packages/core/test/models.test.ts | 74 +++++++++++ .../llm/src/protocols/openai-responses.ts | 7 +- .../test/provider/openai-responses.test.ts | 38 ++++++ packages/opencode/src/provider/transform.ts | 7 + packages/opencode/test/provider/astra.test.ts | 124 ++++++++++++++++++ 7 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/provider/astra.test.ts diff --git a/README.md b/README.md index 8d966278bf..44db26b2d5 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,16 @@ BrowserCode supports any model you can reach with an API key, plus [every provid Use `/connect` in the TUI, or set provider API keys in your environment. +For GPT-6 Astra, set `OPENAI_API_KEY` and select `openai/gpt-6-astra`: + +```bash +bcode run -m openai/gpt-6-astra --variant high "Your task" +``` + +Supported reasoning variants: `low`, `medium`, `high`, `xhigh`, `max` (default: `medium`). +Astra uses the Responses API for tool calls. This requires a build containing Astra support; +the published `0.1.20` binary predates it. + Recommended models from current BU Bench evals: - Best performance: `claude-opus-4-8` diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 0a5d0f455b..4f246e8393 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -114,6 +114,30 @@ export const Model = Schema.Struct({ }) export type Model = Schema.Schema.Type +// Temporary fallback until models.dev lists Astra. Never replace catalog metadata. +// https://developers.openai.com/api/docs/models/gpt-6-astra +// https://developers.openai.com/api/docs/pricing +const astra: Model = { + id: "gpt-6-astra", + name: "GPT-6 Astra", + family: "gpt-6", + release_date: "", + attachment: true, + reasoning: true, + temperature: false, + tool_call: true, + reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1050000, input: 922000, output: 128000 }, + cost: { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + tiers: [{ tier: { type: "context", size: 272000 }, input: 20, output: 75, cache_read: 2, cache_write: 25 }], + }, +} + export const Provider = Schema.Struct({ api: Schema.optional(Schema.String), name: Schema.String, @@ -222,7 +246,19 @@ const layer = Layer.effect( }), ) return JSON.parse(text) as Record - }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + }).pipe( + Effect.map((catalog) => { + if (source !== "https://models.dev" || Flag.OPENCODE_MODELS_PATH) return catalog + const openai = catalog.openai + if (!openai || openai.models[astra.id]) return catalog + return { + ...catalog, + openai: { ...openai, models: { ...openai.models, [astra.id]: astra } }, + } + }), + Effect.withSpan("ModelsDev.populate"), + Effect.orDie, + ) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index a3ed86dc8c..4bf7196deb 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -127,6 +127,80 @@ const initialState: MockState = { } describe("ModelsDev Service", () => { + it.live("fills missing Astra metadata without changing the cached catalog", () => + Effect.gen(function* () { + const catalog = { ...fixture, openai: { id: "openai", name: "OpenAI", env: ["OPENAI_API_KEY"], models: {} } } + yield* writeCache(catalog) + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result.acme).toEqual(fixture.acme) + expect(result.openai.models["gpt-6-astra"]).toMatchObject({ + reasoning: true, + temperature: false, + tool_call: true, + reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], + limit: { context: 1050000, input: 922000, output: 128000 }, + cost: { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + tiers: [{ tier: { type: "context", size: 272000 }, input: 20, output: 75, cache_read: 2, cache_write: 25 }], + }, + }) + expect(JSON.parse(yield* Effect.promise(() => readFile(cacheFile, "utf8")))).toEqual(catalog) + expect((yield* Ref.get(state)).calls).toEqual([]) + }), + ) + + it.live("prefers Astra metadata from the catalog over the fallback", () => + Effect.gen(function* () { + const catalog = { + openai: { + id: "openai", + name: "OpenAI", + env: [], + models: { + "gpt-6-astra": { ...fixture.acme.models["acme-1"], id: "gpt-6-astra", name: "Catalog Astra" }, + }, + }, + } + yield* writeCache(catalog) + const state = yield* Ref.make(initialState) + expect( + yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ), + ).toEqual(catalog) + }), + ) + + it.live("does not inject Astra into an explicitly configured catalog file", () => + Effect.gen(function* () { + const catalog = { openai: { id: "openai", name: "OpenAI", env: [], models: {} } } + yield* writeCache(catalog) + const state = yield* Ref.make(initialState) + yield* Effect.acquireUseRelease( + Effect.sync(() => { + Flag.OPENCODE_MODELS_PATH = cacheFile + }), + () => + provided( + state, + ModelsDev.Service.use((s) => s.get()), + ).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toEqual(catalog)))), + () => + Effect.sync(() => { + Flag.OPENCODE_MODELS_PATH = undefined + }), + ) + }), + ) + it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { yield* writeCache(fixture) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c92..ede28593c4 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -135,7 +135,7 @@ const OpenAIResponsesCoreFields = { include: optionalArray(OpenAIOptions.OpenAIResponseIncludable), reasoning: Schema.optional( Schema.Struct({ - effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + effort: Schema.optional(Schema.Union([OpenAIOptions.OpenAIReasoningEffort, Schema.Literal("max")])), summary: Schema.optional(Schema.Literal("auto")), }), ), @@ -457,7 +457,10 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const store = OpenAIOptions.store(request) const promptCacheKey = OpenAIOptions.promptCacheKey(request) const effort = OpenAIOptions.reasoningEffort(request) - if (effort && !OpenAIOptions.isReasoningEffort(effort)) + const astra = request.model.id === "gpt-6-astra" + if (astra && (effort === "none" || effort === "minimal")) + return yield* invalid(`GPT-6 Astra does not support reasoning effort ${effort}; use low or higher`) + if (effort && !OpenAIOptions.isReasoningEffort(effort) && !(astra && effort === "max")) return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`) const summary = OpenAIOptions.reasoningSummary(request) const include = OpenAIOptions.include(request) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad51af..3160932b56 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -40,6 +40,44 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool } describe("OpenAI Responses route", () => { + for (const effort of ["low", "medium", "high", "xhigh", "max"] as const) { + it.effect(`prepares Astra reasoning effort ${effort} without sending a request`, () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).responses("gpt-6-astra"), + prompt: "hi", + providerOptions: { + openai: { reasoningEffort: effort, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }, + }, + }), + ) + expect(prepared.body.reasoning).toEqual({ effort, summary: "auto" }) + expect(prepared.body.store).toBe(false) + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + }), + ) + } + + for (const [id, effort] of [ + ["gpt-6-astra", "none"], + ["gpt-6-astra", "minimal"], + ["gpt-5.5", "max"], + ] as const) { + it.effect(`rejects unsupported ${id} effort ${effort} before sending`, () => + Effect.gen(function* () { + const result = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ apiKey: "test" }).responses(id), + prompt: "hi", + providerOptions: { openai: { reasoningEffort: effort } }, + }), + ).pipe(Effect.result) + expect(result._tag).toBe("Failure") + }), + ) + } + it.effect("prepares OpenAI Responses target", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare(request) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 2df9fe7448..61edbe653c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -584,6 +584,7 @@ function gpt5ChatReasoningEfforts(apiId: string) { // to strongest. function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() + if (id === "gpt-6-astra") return ["low", "medium", "high", "xhigh", "max"] if (id.includes("deep-research")) return ["medium"] const chatEfforts = gpt5ChatReasoningEfforts(id) if (chatEfforts) return chatEfforts @@ -1224,6 +1225,12 @@ export function options(input: { return result } + if (input.model.api.npm === "@ai-sdk/openai" && input.model.api.id === "gpt-6-astra") { + result["reasoningEffort"] = "medium" + result["reasoningSummary"] = "auto" + result["include"] = INCLUDE_ENCRYPTED_REASONING + } + if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { if (!input.model.api.id.includes("gpt-5-pro")) { result["reasoningEffort"] = "medium" diff --git a/packages/opencode/test/provider/astra.test.ts b/packages/opencode/test/provider/astra.test.ts new file mode 100644 index 0000000000..10dd09ae4c --- /dev/null +++ b/packages/opencode/test/provider/astra.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test" +import { createOpenAI } from "@ai-sdk/openai" +import { generateText, jsonSchema, stepCountIs, tool } from "ai" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Provider } from "../../src/provider/provider" +import { ProviderTransform } from "../../src/provider/transform" + +const model: Provider.Model = { + id: ModelV2.ID.make("astra-alias"), + providerID: ProviderV2.ID.make("openai"), + api: { id: "gpt-6-astra", url: "https://api.openai.test/v1", npm: "@ai-sdk/openai" }, + name: "Astra", + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + interleaved: false, + input: { text: true, image: true, audio: false, video: false, pdf: false }, + output: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + cost: { input: 10, output: 50, cache: { read: 1, write: 12.5 } }, + limit: { context: 1050000, input: 922000, output: 128000 }, + status: "active", + options: {}, + headers: {}, + release_date: "", +} + +describe("GPT-6 Astra", () => { + test("uses the API ID for the five supported variants and valid defaults", () => { + const variants = ProviderTransform.variants(model) + expect(Object.keys(variants)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(ProviderTransform.options({ model, sessionID: "test", providerOptions: {} })).toMatchObject({ + store: false, + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + expect(ProviderTransform.smallOptions({ ...model, variants })).toMatchObject({ reasoningEffort: "low" }) + }) + + for (const effort of ["low", "medium", "high", "xhigh", "max"]) { + test(`${effort} reaches Responses and replays encrypted reasoning through a tool round trip`, async () => { + const requests: Array<{ url: string; body: Record }> = [] + const sdk = createOpenAI({ + apiKey: "offline-test-key", + baseURL: model.api.url, + fetch: Object.assign( + async (url: Parameters[0], init?: Parameters[1]) => { + requests.push({ url: String(url), body: JSON.parse(String(init?.body)) }) + return Response.json({ + id: `resp_${requests.length}`, + created_at: 0, + model: model.api.id, + output: + requests.length === 1 + ? [ + { + type: "reasoning", + id: "rs_1", + summary: [{ type: "summary_text", text: "Checking." }], + encrypted_content: "test-encrypted-state", + }, + { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }, + ] + : [ + { + type: "message", + id: "msg_1", + role: "assistant", + content: [{ type: "output_text", text: "Done.", annotations: [] }], + }, + ], + usage: { input_tokens: 20, output_tokens: 10, total_tokens: 30 }, + }) + }, + { preconnect() {} }, + ), + }) + const defaults = ProviderTransform.options({ model, sessionID: "test", providerOptions: {} }) + const result = await generateText({ + model: sdk.responses(model.api.id), + prompt: "Call lookup.", + tools: { + lookup: tool({ + inputSchema: jsonSchema>({ type: "object", properties: {} }), + execute: async () => "found", + }), + }, + stopWhen: stepCountIs(2), + maxRetries: 0, + // Even explicitly configured sampling settings must not reach Astra. + temperature: 0.5, + topP: 0.9, + providerOptions: ProviderTransform.providerOptions(model, { + ...defaults, + ...ProviderTransform.variants(model)[effort], + }), + }) + expect(result.text).toBe("Done.") + expect(requests).toHaveLength(2) + for (const request of requests) { + expect(request.url).toBe("https://api.openai.test/v1/responses") + expect(request.body).toMatchObject({ + model: "gpt-6-astra", + store: false, + reasoning: { effort, summary: "auto" }, + include: ["reasoning.encrypted_content"], + }) + expect(request.body).not.toHaveProperty("temperature") + expect(request.body).not.toHaveProperty("top_p") + } + expect(requests[1].body.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "reasoning", encrypted_content: "test-encrypted-state" }), + expect.objectContaining({ type: "function_call", call_id: "call_1", name: "lookup" }), + expect.objectContaining({ type: "function_call_output", call_id: "call_1", output: "found" }), + ]), + ) + }) + } +})