diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c7bfd80d..e4850170 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Made the "No API key for provider" error from `stream`/`complete` actionable for OpenCode Go/Zen subscription providers in headless runs (#755). The subscription is itself an API key (`OPENCODE_API_KEY`, created at https://opencode.ai/auth), not a separate OAuth/session token; the new `formatProviderCredentialHint` helper (composed into `formatMissingApiKeyError`) names the env var GJC reads, warns that a project `.env` is intentionally ignored for provider credentials, and points OpenCode users at the one-time interactive `gjc auth-broker login ` credential capture to run before headless/print mode. No auth behavior changed. + ## [0.5.3] - 2026-06-16 ### Added diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 04f9671c..b9a73b38 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -194,6 +194,58 @@ export function listProvidersWithEnvKey(): string[] { return Object.keys(serviceProviderMap); } +/** + * Subscription-style providers whose "subscription" is delivered as an API key + * (created at https://opencode.ai/auth), not a separate OAuth/session token. + * Used to give OpenCode users an accurate headless auth diagnostic (#755). + */ +const OPENCODE_SUBSCRIPTION_PROVIDERS = new Set(["opencode-go", "opencode-zen"]); + +/** + * Provider-specific credential guidance appended to "no credential" errors. + * + * Headless GJC has no interactive `/login` TUI, so a bare "No API key" / + * "No credentials" error left users — OpenCode Go subscribers especially + * (#755) — unsure what signal GJC actually reads. OpenCode subscriptions are + * themselves API keys, so this names the env var GJC reads for the provider, + * warns that a project `.env` is intentionally ignored for provider + * credentials, and points OpenCode users at one-time interactive CLI credential capture. + * + * Returns an empty string when the provider has no env-var key and no special + * handling, so callers can append it unconditionally. + */ +export function formatProviderCredentialHint(provider: string): string { + const resolver = serviceProviderMap[provider]; + const envVar = typeof resolver === "string" ? resolver : undefined; + const isOpenCodeSubscription = OPENCODE_SUBSCRIPTION_PROVIDERS.has(provider); + const parts: string[] = []; + if (isOpenCodeSubscription) { + parts.push( + "OpenCode subscriptions authenticate with an API key (created at https://opencode.ai/auth), not a separate session/OAuth token.", + ); + } + if (envVar) { + parts.push( + `Headless GJC reads this provider's key from ${envVar} (exported in your shell or set in ~/.gjc/.env).`, + ); + parts.push("A value set only in a project .env is intentionally ignored for provider credentials."); + } + if (isOpenCodeSubscription) { + parts.push(`Or run \`gjc auth-broker login ${provider}\` once before headless/print mode to store the key interactively.`); + } + return parts.join(" "); +} + +/** + * Build an actionable "missing API key" error for a provider, used by the + * low-level `stream`/`complete` entry points (#755). + */ +export function formatMissingApiKeyError(provider: string): string { + const base = `No API key for provider: ${provider}.`; + const hint = formatProviderCredentialHint(provider); + return hint ? `${base} ${hint}` : base; +} + export function stream( model: Model, context: Context, @@ -208,7 +260,7 @@ export function stream( if (isGitLabDuoModel(model)) { const apiKey = (options as StreamOptions | undefined)?.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new Error(formatMissingApiKeyError(model.provider)); } return streamGitLabDuo(model, context, { ...(options as SimpleStreamOptions | undefined), @@ -226,7 +278,7 @@ export function stream( const apiKey = options?.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new Error(formatMissingApiKeyError(model.provider)); } const providerOptions = { ...options, apiKey }; @@ -410,7 +462,7 @@ export function streamSimple( const apiKey = options?.apiKey || getEnvApiKey(model.provider); if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + throw new Error(formatMissingApiKeyError(model.provider)); } // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens diff --git a/packages/ai/test/provider-credential-error.test.ts b/packages/ai/test/provider-credential-error.test.ts new file mode 100644 index 00000000..a0f31599 --- /dev/null +++ b/packages/ai/test/provider-credential-error.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { formatMissingApiKeyError, formatProviderCredentialHint, streamSimple } from "@gajae-code/ai/stream"; +import type { Context, Model } from "@gajae-code/ai/types"; + +const originalOpenCodeApiKey = Bun.env.OPENCODE_API_KEY; + +afterEach(() => { + if (originalOpenCodeApiKey === undefined) delete Bun.env.OPENCODE_API_KEY; + else Bun.env.OPENCODE_API_KEY = originalOpenCodeApiKey; +}); + +describe("formatProviderCredentialHint", () => { + it("explains OpenCode Go subscription auth and the headless signal (#755)", () => { + const hint = formatProviderCredentialHint("opencode-go"); + expect(hint).toContain("OpenCode subscriptions authenticate with an API key"); + expect(hint).toContain("https://opencode.ai/auth"); + expect(hint).toContain("not a separate session/OAuth token"); + expect(hint).toContain("OPENCODE_API_KEY"); + expect(hint).toContain("~/.gjc/.env"); + expect(hint).toContain("project .env is intentionally ignored"); + expect(hint).toContain("once before headless/print mode to store the key interactively"); + expect(hint).not.toContain("non-interactively"); + }); + + it("covers opencode-zen with the same shape", () => { + const hint = formatProviderCredentialHint("opencode-zen"); + expect(hint).toContain("OPENCODE_API_KEY"); + expect(hint).toContain("gjc auth-broker login opencode-zen"); + }); + + it("names the env var for a plain env-key provider without an OpenCode note or invalid login command", () => { + const hint = formatProviderCredentialHint("groq"); + expect(hint).toContain("GROQ_API_KEY"); + expect(hint).toContain("project .env is intentionally ignored"); + expect(hint).not.toContain("OpenCode"); + // groq is not an auth-broker OAuth provider, so we must not suggest a login that would fail. + expect(hint).not.toContain("gjc auth-broker login"); + }); + + it("returns an empty hint for providers without a static env-var key", () => { + // anthropic resolves via a function (OAuth/foundry), so there is no single env var to name. + expect(formatProviderCredentialHint("anthropic")).toBe(""); + expect(formatProviderCredentialHint("totally-unknown-provider")).toBe(""); + }); +}); + +describe("formatMissingApiKeyError", () => { + it("prefixes the base error and appends OpenCode guidance", () => { + const message = formatMissingApiKeyError("opencode-go"); + expect(message).toContain("No API key for provider: opencode-go."); + expect(message).toContain("OPENCODE_API_KEY"); + expect(message).toContain("once before headless/print mode to store the key interactively"); + }); + + it("falls back to the bare base error for providers with no hint", () => { + expect(formatMissingApiKeyError("anthropic")).toBe("No API key for provider: anthropic."); + }); +}); + +describe("streamSimple missing credentials", () => { + it("throws OpenCode Go headless guidance on the actual no-key path (#755)", () => { + delete Bun.env.OPENCODE_API_KEY; + const model: Model<"openai-completions"> = { + api: "openai-completions", + provider: "opencode-go", + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + baseUrl: "https://opencode.ai/zen/go/v1", + }; + const context: Context = { + messages: [{ role: "user", content: "hi", timestamp: 0 }], + }; + + expect(() => streamSimple(model, context)).toThrow("OpenCode subscriptions authenticate with an API key"); + expect(() => streamSimple(model, context)).toThrow("OPENCODE_API_KEY"); + expect(() => streamSimple(model, context)).toThrow("not a separate session/OAuth token"); + }); +}); diff --git a/packages/coding-agent/src/setup/model-onboarding-guidance.ts b/packages/coding-agent/src/setup/model-onboarding-guidance.ts index fc380ae5..e149ac10 100644 --- a/packages/coding-agent/src/setup/model-onboarding-guidance.ts +++ b/packages/coding-agent/src/setup/model-onboarding-guidance.ts @@ -1,3 +1,5 @@ +import { formatProviderCredentialHint } from "@gajae-code/ai/stream"; + export const MODEL_ONBOARDING_API_PROVIDER_COMMAND = "/provider add --compat --provider --base-url --api-key-env --model "; export const MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND = "/provider add --preset "; @@ -26,14 +28,19 @@ export function formatNoModelOnboardingError(): string { } export function formatNoCredentialOnboardingError(providerId: string): string { - return [ + const lines = [ `No credentials found for ${providerId}.`, "", `For MiniMax/GLM presets, configure credentials with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND} --preset ).`, `For custom API-compatible providers, use ${MODEL_ONBOARDING_API_PROVIDER_COMMAND}.`, - `For OAuth/subscription providers, use ${MODEL_ONBOARDING_OAUTH_COMMAND}.`, + `For OAuth/subscription providers, use ${MODEL_ONBOARDING_OAUTH_COMMAND} (interactive; not available in headless/print mode).`, + ]; + const headlessHint = formatProviderCredentialHint(providerId); + if (headlessHint) lines.push(headlessHint); + lines.push( "Then run /model to select a configured model or assign it to DEFAULT, EXECUTOR, ARCHITECT, PLANNER, or CRITIC.", - ].join("\n"); + ); + return lines.join("\n"); } export function formatNoModelsAvailableFallback(): string { diff --git a/packages/coding-agent/test/model-onboarding-guidance.test.ts b/packages/coding-agent/test/model-onboarding-guidance.test.ts index 278985f3..13002f6e 100644 --- a/packages/coding-agent/test/model-onboarding-guidance.test.ts +++ b/packages/coding-agent/test/model-onboarding-guidance.test.ts @@ -93,6 +93,17 @@ describe("model onboarding guidance", () => { } }); + it("adds headless-aware credential guidance for OpenCode Go subscription providers (#755)", () => { + const text = formatNoCredentialOnboardingError("opencode-go"); + expectProviderOnboardingGuidance(text); + expect(text).toContain("interactive; not available in headless/print mode"); + expect(text).toContain("OpenCode subscriptions authenticate with an API key"); + expect(text).toContain("https://opencode.ai/auth"); + expect(text).toContain("OPENCODE_API_KEY"); + expect(text).toContain("project .env is intentionally ignored"); + expect(text).toContain("gjc auth-broker login opencode-go"); + }); + it("updates /model status output with provider setup and login routes", async () => { const command = BUILTIN_SLASH_COMMANDS_INTERNAL.find(entry => entry.name === "model"); expect(command?.handle).toBeTruthy(); @@ -132,7 +143,7 @@ describe("model onboarding guidance", () => { } finally { await session.dispose(); } - }); + }, 30000); it("uses shared provider onboarding text for AgentSession no-model and no-credential errors", async () => { const noModelDir = await createTempDir();