Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <provider>` credential capture to run before headless/print mode. No auth behavior changed.

## [0.5.3] - 2026-06-16

### Added
Expand Down
58 changes: 55 additions & 3 deletions packages/ai/src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TApi extends Api>(
model: Model<TApi>,
context: Context,
Expand All @@ -208,7 +260,7 @@ export function stream<TApi extends Api>(
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),
Expand All @@ -226,7 +278,7 @@ export function stream<TApi extends Api>(

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 };

Expand Down Expand Up @@ -410,7 +462,7 @@ export function streamSimple<TApi extends Api>(

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
Expand Down
78 changes: 78 additions & 0 deletions packages/ai/test/provider-credential-error.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate OpenCode credentials in the no-key test

When OPENCODE_API_KEY is exported or present in a user/GJC env file, $credentialEnv has already captured that value while importing @gajae-code/ai/stream, so delete Bun.env.OPENCODE_API_KEY here does not make getEnvApiKey("opencode-go") return undefined. In that environment streamSimple does not throw the missing-key guidance (and may start a real provider request), making this new regression test fail for contributors/CI with OpenCode credentials; run the assertion in an isolated process/env before importing stream or otherwise suppress the credential snapshots.

Useful? React with 👍 / 👎.

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");
});
});
13 changes: 10 additions & 3 deletions packages/coding-agent/src/setup/model-onboarding-guidance.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatProviderCredentialHint } from "@gajae-code/ai/stream";

export const MODEL_ONBOARDING_API_PROVIDER_COMMAND =
"/provider add --compat <openai|anthropic> --provider <id> --base-url <url> --api-key-env <ENV> --model <model>";
export const MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND = "/provider add --preset <minimax|minimax-cn|glm>";
Expand Down Expand Up @@ -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 <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).`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a coding-agent changelog entry

This changes packages/coding-agent user-facing no-credential onboarding text, but the only changelog update is in packages/ai/CHANGELOG.md; the root AGENTS.md contract says package changelogs under packages/*/CHANGELOG.md need Unreleased entries. Without a packages/coding-agent/CHANGELOG.md entry, the coding-agent release notes omit this behavior change.

Useful? React with 👍 / 👎.

];
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 {
Expand Down
13 changes: 12 additions & 1 deletion packages/coding-agent/test/model-onboarding-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading