@sayknow-cli/ai 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.1] - 2026-06-23
6
+
7
+ ### Changed
8
+
9
+ - Reworked the unofficial, opt-in `glm-zcode` provider to mirror how the ZCode desktop app actually reaches GLM: after the ZCode OAuth handshake it now auto-provisions a real Z.AI API key and calls `api.z.ai/api/anthropic` directly, instead of the `zcode.z.ai` coding-plan gateway that required an Aliyun captcha and a ZCode-JWT-bound plan entitlement. Requests also carry ZCode client source headers (`User-Agent: ZCode/<ver>`, `X-ZCode-Agent: glm`, plus platform/locale/timezone), so Z.AI recognizes the caller as the ZCode client (#1013, #1016, #1017).
10
+
11
+ ## [0.7.0] - 2026-06-22
12
+
13
+ ### Added
14
+
15
+ - Added the bundled `google-gemini-cli/gemini-3.5-flash` model entry so Gemini CLI users can select the model from the static registry (#965).
16
+
5
17
  ## [0.6.4] - 2026-06-20
6
18
 
7
19
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  import type { AuthCredential } from "../auth-storage";
2
- import type { CredentialDisableResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types";
2
+ import type { CredentialDisableResponse, CredentialIfAbsentUploadResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types";
3
3
  export interface AuthBrokerClientOptions {
4
4
  /** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
5
5
  url: string;
@@ -63,4 +63,5 @@ export declare class AuthBrokerClient {
63
63
  refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse>;
64
64
  disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
65
65
  uploadCredential(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialUploadResponse>;
66
+ uploadCredentialIfAbsent(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialIfAbsentUploadResponse>;
66
67
  }
@@ -1,4 +1,4 @@
1
- import { type AuthCredential, type AuthCredentialStore, type OAuthCredential, type StoredAuthCredential } from "../auth-storage";
1
+ import { type AuthCredential, type AuthCredentialIfAbsentResult, type AuthCredentialStore, type OAuthCredential, type StoredAuthCredential } from "../auth-storage";
2
2
  import type { Provider } from "../types";
3
3
  import type { UsageReport } from "../usage";
4
4
  import type { OAuthCredentials } from "../utils/oauth/types";
@@ -44,6 +44,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
44
44
  }): Promise<void>;
45
45
  replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[];
46
46
  upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[];
47
+ upsertAuthCredentialForProviderIfAbsent(_provider: string, _credential: AuthCredential): AuthCredentialIfAbsentResult;
47
48
  deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void;
48
49
  /**
49
50
  * Upsert a single credential through the broker. The broker server is the
@@ -53,6 +54,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
53
54
  * any concurrent peer (refresh, generation bump) stays in sync.
54
55
  */
55
56
  upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
57
+ upsertAuthCredentialRemoteIfAbsent(provider: string, credential: AuthCredential): Promise<AuthCredentialIfAbsentResult>;
56
58
  /**
57
59
  * Replace-all semantics: disable every active credential for the provider,
58
60
  * then upload each of the new credentials. Used by API-key login so a new
@@ -5,7 +5,7 @@
5
5
  * clients use `access` tokens directly and call back to the broker when a
6
6
  * credential expires or a 401 surfaces on a supposedly-fresh credential.
7
7
  */
8
- import type { AuthCredential, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
8
+ import type { AuthCredential, AuthCredentialIfAbsentReason, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
9
9
  import type { UsageReport } from "../usage";
10
10
  /** GET /v1/healthz response body. */
11
11
  export interface HealthzResponse {
@@ -56,6 +56,11 @@ export interface CredentialUploadRequest {
56
56
  export interface CredentialUploadResponse {
57
57
  entries: AuthCredentialSnapshotEntry[];
58
58
  }
59
+ export interface CredentialIfAbsentUploadResponse {
60
+ inserted: boolean;
61
+ reason: AuthCredentialIfAbsentReason;
62
+ entries: AuthCredentialSnapshotEntry[];
63
+ }
59
64
  /**
60
65
  * SSE event kinds emitted on `GET /v1/snapshot/stream`. The same value is set
61
66
  * as the SSE `event:` name (load-bearing for clients) **and** embedded as a
@@ -410,3 +410,33 @@ export declare const credentialUploadResponseSchema: z.ZodObject<{
410
410
  identityKey: z.ZodNullable<z.ZodString>;
411
411
  }, z.core.$strict>>;
412
412
  }, z.core.$strict>;
413
+ export declare const credentialIfAbsentUploadResponseSchema: z.ZodObject<{
414
+ inserted: z.ZodBoolean;
415
+ reason: z.ZodEnum<{
416
+ inserted: "inserted";
417
+ "skipped-existing": "skipped-existing";
418
+ "skipped-existing-config": "skipped-existing-config";
419
+ "skipped-existing-env": "skipped-existing-env";
420
+ "skipped-existing-fallback": "skipped-existing-fallback";
421
+ "skipped-existing-runtime": "skipped-existing-runtime";
422
+ "skipped-invalid": "skipped-invalid";
423
+ }>;
424
+ entries: z.ZodArray<z.ZodObject<{
425
+ id: z.ZodNumber;
426
+ provider: z.ZodString;
427
+ credential: z.ZodDiscriminatedUnion<[z.ZodObject<{
428
+ type: z.ZodLiteral<"oauth">;
429
+ access: z.ZodString;
430
+ expires: z.ZodNumber;
431
+ enterpriseUrl: z.ZodOptional<z.ZodString>;
432
+ projectId: z.ZodOptional<z.ZodString>;
433
+ email: z.ZodOptional<z.ZodString>;
434
+ accountId: z.ZodOptional<z.ZodString>;
435
+ refresh: z.ZodLiteral<"__remote__">;
436
+ }, z.core.$strict>, z.ZodObject<{
437
+ type: z.ZodLiteral<"api_key">;
438
+ key: z.ZodString;
439
+ }, z.core.$strict>], "type">;
440
+ identityKey: z.ZodNullable<z.ZodString>;
441
+ }, z.core.$strict>>;
442
+ }, z.core.$strict>;
@@ -102,6 +102,19 @@ export interface AuthCredentialSnapshotEntry {
102
102
  credential: SnapshotCredential;
103
103
  identityKey: string | null;
104
104
  }
105
+ export type AuthCredentialIfAbsentReason = "inserted" | "skipped-existing" | "skipped-existing-runtime" | "skipped-existing-config" | "skipped-existing-env" | "skipped-existing-fallback" | "skipped-invalid";
106
+ export interface AuthCredentialIfAbsentResult {
107
+ inserted: boolean;
108
+ reason: AuthCredentialIfAbsentReason;
109
+ provider: string;
110
+ entries: StoredAuthCredential[];
111
+ }
112
+ export interface AuthCredentialIfAbsentSnapshotResult {
113
+ inserted: boolean;
114
+ reason: AuthCredentialIfAbsentReason;
115
+ provider: string;
116
+ entries: AuthCredentialSnapshotEntry[];
117
+ }
105
118
  /**
106
119
  * Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and
107
120
  * served by the auth-broker server on `GET /v1/snapshot`.
@@ -128,6 +141,7 @@ export interface AuthCredentialStore {
128
141
  tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
129
142
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
130
143
  upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
144
+ upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
131
145
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
132
146
  getCache(key: string, options?: {
133
147
  includeExpired?: boolean;
@@ -201,6 +215,7 @@ export interface AuthCredentialStore {
201
215
  * post-write read path is consistent.
202
216
  */
203
217
  upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
218
+ upsertAuthCredentialRemoteIfAbsent?(provider: string, credential: AuthCredential): Promise<AuthCredentialIfAbsentResult>;
204
219
  /**
205
220
  * Optional async write hook for replace-all semantics (e.g. API-key login
206
221
  * overwriting any previous keys for the same provider). When present,
@@ -417,6 +432,7 @@ export declare class AuthStorage {
417
432
  * Set credential for a provider.
418
433
  */
419
434
  set(provider: string, credential: AuthCredentialEntry): Promise<void>;
435
+ importCredentialIfAbsent(provider: string, credential: AuthCredential): Promise<AuthCredentialIfAbsentSnapshotResult>;
420
436
  /**
421
437
  * Remove credential for a provider.
422
438
  */
@@ -615,6 +631,7 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
615
631
  listAuthCredentials(provider?: string): StoredAuthCredential[];
616
632
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
617
633
  upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
634
+ upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
618
635
  updateAuthCredential(id: number, credential: AuthCredential): void;
619
636
  deleteAuthCredential(id: number, disabledCause: string): void;
620
637
  /**
@@ -14,3 +14,6 @@ export declare function cursorModelManagerOptions(config?: CursorModelManagerCon
14
14
  export interface ZaiModelManagerConfig {
15
15
  }
16
16
  export declare function zaiModelManagerOptions(_config?: ZaiModelManagerConfig): ModelManagerOptions<"anthropic-messages">;
17
+ export interface GlmZcodeModelManagerConfig {
18
+ }
19
+ export declare function glmZcodeModelManagerOptions(_config?: GlmZcodeModelManagerConfig): ModelManagerOptions<"anthropic-messages">;
@@ -9,9 +9,24 @@ export type AnthropicHeaderOptions = {
9
9
  stream?: boolean;
10
10
  modelHeaders?: Record<string, string>;
11
11
  isCloudflareAiGateway?: boolean;
12
+ /**
13
+ * Attach ZCode client "source" headers (User-Agent: ZCode/<ver>, X-Title,
14
+ * X-ZCode-Agent: glm, X-Platform, etc.) so api.z.ai recognizes the caller as
15
+ * the ZCode client, exactly like ZCode's `buildZCodeSourceHeaders` does for
16
+ * GLM providers. glm-zcode only.
17
+ */
18
+ zcodeSourceHeaders?: boolean;
12
19
  };
13
20
  export declare function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined;
14
21
  export declare function buildBetaHeader(baseBetas: string[], extraBetas: string[]): string;
22
+ /**
23
+ * Replicates ZCode's `buildZCodeSourceHeaders()` + GLM `X-ZCode-Agent` tag
24
+ * (host bundle `Bl` / `buildConnectivitySourceHeaders` for GLM providers), so
25
+ * api.z.ai sees skc's glm-zcode requests as the ZCode client. Dynamic values
26
+ * (platform/arch, locale, timezone, OS version) are resolved at runtime exactly
27
+ * as ZCode does; printable-ASCII-only and conditionally omitted when empty.
28
+ */
29
+ export declare function buildZCodeSourceHeaders(): Record<string, string>;
15
30
  export declare function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<string, string>;
16
31
  type AnthropicCacheControl = {
17
32
  type: "ephemeral";
@@ -48,7 +48,7 @@ export interface ThinkingConfig {
48
48
  /** Provider-specific transport used to encode the selected effort. */
49
49
  mode: ThinkingControlMode;
50
50
  }
51
- export type KnownProvider = "alibaba-coding-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "gitlab-duo" | "cursor" | "deepseek" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio";
51
+ export type KnownProvider = "alibaba-coding-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "gitlab-duo" | "cursor" | "deepseek" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "glm-zcode" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio";
52
52
  export type Provider = KnownProvider | string;
53
53
  import type { Effort } from "./model-thinking";
54
54
  /** Token budgets for each thinking level (token-based providers only) */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * GLM ZCode OAuth flow (UNOFFICIAL, opt-in).
3
+ *
4
+ * Replicates how the ZCode desktop app turns a Z.AI login into usable GLM model
5
+ * access. This is NOT an official Z.AI OAuth client: it reuses ZCode's authorize
6
+ * page, broker, and a custom-protocol redirect. It may break at any time and may
7
+ * violate ZCode/Z.AI Terms of Service. Endpoints/client id are overridable via
8
+ * `ZCODE_OAUTH_*` environment variables.
9
+ *
10
+ * Verified end-to-end against the ZCode host bundle (`resolveZaiApiKey` /
11
+ * `resolveBizApiKey`) and live traffic:
12
+ * 1. Authorize: GET {authorize}?redirect_uri=zcode://oauth/callback&response_type=code&client_id=...&state=...
13
+ * (custom-protocol redirect → a CLI cannot catch it, so the user pastes the code/redirect URL)
14
+ * 2. Broker: POST {broker} { provider:"zai", code, redirect_uri, state }
15
+ * → { data: { token: <ZCode JWT>, zai: { access_token: <upstream Z.AI token> } } }
16
+ * 3. Business: POST {z/login} { token: <upstream Z.AI token> } → { data: { access_token: <business token> } }
17
+ * 4. Provision: with the business token, GET getCustomerInfo → default org/project,
18
+ * GET/POST .../api_keys (find/create a key named "zcode-api-key"),
19
+ * GET .../api_keys/copy/{id} → secretKey ⇒ a real Z.AI API key "{id}.{secret}".
20
+ *
21
+ * Credential mapping:
22
+ * - `access` = the provisioned **Z.AI API key** ("{id}.{secret}"). Model requests go to
23
+ * `https://api.z.ai/api/anthropic/v1/messages` with `Authorization: Bearer <key>`
24
+ * (exactly like a dashboard key) — NO zcode.z.ai gateway, NO captcha.
25
+ * - `refresh` = the upstream Z.AI OAuth access token (used to re-provision the key).
26
+ * The API key is long-lived, so `expires` is set far in the future.
27
+ *
28
+ * This provider must NEVER force `isOAuth=true`: the key is a plain Z.AI API key, and
29
+ * api.z.ai is not api.anthropic.com, so the Anthropic path already emits a plain bearer.
30
+ */
31
+ import { OAuthCallbackFlow } from "./callback-server";
32
+ import type { OAuthController, OAuthCredentials } from "./types";
33
+ export declare const GLM_ZCODE_REFRESH_SKEW_MS: number;
34
+ /** Default endpoints / client id. Override via the matching `ZCODE_OAUTH_*` env vars. */
35
+ export declare const GLM_ZCODE_OAUTH_AUTHORIZE_URL = "https://chat.z.ai/api/oauth/authorize";
36
+ export declare const GLM_ZCODE_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg";
37
+ export declare const GLM_ZCODE_OAUTH_REDIRECT_URI = "zcode://oauth/callback";
38
+ export declare const GLM_ZCODE_OAUTH_BROKER_TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token";
39
+ export declare const GLM_ZCODE_ZAI_LOGIN_URL = "https://api.z.ai/api/auth/z/login";
40
+ export declare const GLM_ZCODE_USERINFO_URL = "https://chat.z.ai/api/oauth/userinfo";
41
+ /** Z.AI business API base (customer/org/project/api-key management). */
42
+ export declare const GLM_ZCODE_ZAI_API_BASE = "https://api.z.ai";
43
+ /** Model API base — the provisioned key is used here, exactly like a dashboard key. */
44
+ export declare const GLM_ZCODE_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
45
+ type FetchImpl = typeof globalThis.fetch;
46
+ /** Configured whenever a client id is available; the real ZCode client id ships as default. */
47
+ export declare function isGlmZcodeOAuthConfigured(): boolean;
48
+ export interface GlmZcodeOAuthFlowOptions {
49
+ fetch?: FetchImpl;
50
+ }
51
+ export declare class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
52
+ #private;
53
+ constructor(ctrl: OAuthController, options?: GlmZcodeOAuthFlowOptions);
54
+ generateAuthUrl(state: string, redirectUri: string): Promise<{
55
+ url: string;
56
+ instructions?: string;
57
+ }>;
58
+ exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials>;
59
+ }
60
+ export declare function loginGlmZcode(ctrl: OAuthController, options?: GlmZcodeOAuthFlowOptions): Promise<OAuthCredentials>;
61
+ export interface GlmZcodeRefreshOptions {
62
+ signal?: AbortSignal;
63
+ fetch?: FetchImpl;
64
+ }
65
+ /**
66
+ * Re-provision the Z.AI API key from the stored upstream token. The key itself is
67
+ * long-lived, so this is rarely needed; if the upstream token has expired it fails
68
+ * loudly and the user must re-login.
69
+ */
70
+ export declare function refreshGlmZcodeToken(credentials: OAuthCredentials, options?: AbortSignal | GlmZcodeRefreshOptions): Promise<OAuthCredentials>;
71
+ export {};
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai";
10
+ export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/ai",
4
- "version": "0.2.7",
4
+ "version": "0.3.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
7
7
  "author": "jaybeyond",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@sayknow-cli/utils": "0.2.7",
46
+ "@sayknow-cli/utils": "0.3.0",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -11,6 +11,7 @@ import type { AuthCredential } from "../auth-storage";
11
11
  import type {
12
12
  CredentialDisableRequest,
13
13
  CredentialDisableResponse,
14
+ CredentialIfAbsentUploadResponse,
14
15
  CredentialRefreshResponse,
15
16
  CredentialUploadRequest,
16
17
  CredentialUploadResponse,
@@ -21,6 +22,7 @@ import type {
21
22
  } from "./types";
22
23
  import {
23
24
  credentialDisableResponseSchema,
25
+ credentialIfAbsentUploadResponseSchema,
24
26
  credentialRefreshResponseSchema,
25
27
  credentialUploadResponseSchema,
26
28
  healthzResponseSchema,
@@ -262,6 +264,19 @@ export class AuthBrokerClient {
262
264
  }) as Promise<CredentialUploadResponse>;
263
265
  }
264
266
 
267
+ async uploadCredentialIfAbsent(
268
+ provider: string,
269
+ credential: AuthCredential,
270
+ signal?: AbortSignal,
271
+ ): Promise<CredentialIfAbsentUploadResponse> {
272
+ const body: CredentialUploadRequest = { provider, credential };
273
+ return this.#request("POST", "/v1/credential/if-absent", {
274
+ body,
275
+ schema: credentialIfAbsentUploadResponseSchema,
276
+ signal,
277
+ }) as Promise<CredentialIfAbsentUploadResponse>;
278
+ }
279
+
265
280
  async #request<TSchema extends ZodType>(
266
281
  method: "GET" | "POST",
267
282
  path: string,
@@ -11,6 +11,7 @@ import { scheduler } from "node:timers/promises";
11
11
  import { logger } from "@sayknow-cli/utils";
12
12
  import {
13
13
  type AuthCredential,
14
+ type AuthCredentialIfAbsentResult,
14
15
  type AuthCredentialSnapshotEntry,
15
16
  type AuthCredentialStore,
16
17
  type OAuthCredential,
@@ -308,6 +309,15 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
308
309
  );
309
310
  }
310
311
 
312
+ upsertAuthCredentialForProviderIfAbsent(
313
+ _provider: string,
314
+ _credential: AuthCredential,
315
+ ): AuthCredentialIfAbsentResult {
316
+ throw new Error(
317
+ "RemoteAuthCredentialStore is read-only on the client. Use `skc auth-broker login <provider>` to mutate credentials.",
318
+ );
319
+ }
320
+
311
321
  deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
312
322
  throw new Error(
313
323
  "RemoteAuthCredentialStore is read-only on the client. Use `skc auth-broker logout <provider>` to mutate credentials.",
@@ -328,6 +338,16 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
328
338
  return this.listAuthCredentials(provider);
329
339
  }
330
340
 
341
+ async upsertAuthCredentialRemoteIfAbsent(
342
+ provider: string,
343
+ credential: AuthCredential,
344
+ ): Promise<AuthCredentialIfAbsentResult> {
345
+ const { inserted, reason, entries } = await this.#client.uploadCredentialIfAbsent(provider, credential);
346
+ this.#applyProviderEntries(provider, entries);
347
+ this.#maybeRefreshSnapshot("upload-if-absent");
348
+ return { inserted, reason, provider, entries: this.listAuthCredentials(provider) };
349
+ }
350
+
331
351
  /**
332
352
  * Replace-all semantics: disable every active credential for the provider,
333
353
  * then upload each of the new credentials. Used by API-key login so a new
@@ -15,6 +15,7 @@ import { parseBind } from "../utils/parse-bind";
15
15
  import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
16
16
  import type {
17
17
  CredentialDisableResponse,
18
+ CredentialIfAbsentUploadResponse,
18
19
  CredentialRefreshResponse,
19
20
  CredentialUploadResponse,
20
21
  HealthzResponse,
@@ -592,6 +593,32 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
592
593
  const response: CredentialDisableResponse = { ok: true };
593
594
  return json(200, response);
594
595
  }
596
+ if (req.method === "POST" && pathname === "/v1/credential/if-absent") {
597
+ const parsed = await parseBody(req, credentialUploadRequestSchema);
598
+ if (!parsed.ok) return parsed.response;
599
+ const { provider, credential } = parsed.data;
600
+ try {
601
+ const result = await opts.storage.importCredentialIfAbsent(provider, credential);
602
+ logger.info("auth-broker credential import-if-absent", {
603
+ provider,
604
+ type: credential.type,
605
+ inserted: result.inserted,
606
+ reason: result.reason,
607
+ providerTotal: result.entries.length,
608
+ peer,
609
+ });
610
+ const response: CredentialIfAbsentUploadResponse = {
611
+ inserted: result.inserted,
612
+ reason: result.reason,
613
+ entries: result.entries,
614
+ };
615
+ return json(200, response);
616
+ } catch (error) {
617
+ const message = error instanceof Error ? error.message : String(error);
618
+ logger.warn("auth-broker upload-if-absent failed", { provider, type: credential.type, peer });
619
+ return json(500, { error: message });
620
+ }
621
+ }
595
622
  if (req.method === "POST" && pathname === "/v1/credential") {
596
623
  const parsed = await parseBody(req, credentialUploadRequestSchema);
597
624
  if (!parsed.ok) return parsed.response;
@@ -6,7 +6,12 @@
6
6
  * credential expires or a 401 surfaces on a supposedly-fresh credential.
7
7
  */
8
8
 
9
- import type { AuthCredential, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
9
+ import type {
10
+ AuthCredential,
11
+ AuthCredentialIfAbsentReason,
12
+ AuthCredentialSnapshot,
13
+ AuthCredentialSnapshotEntry,
14
+ } from "../auth-storage";
10
15
  import type { UsageReport } from "../usage";
11
16
 
12
17
  /** GET /v1/healthz response body. */
@@ -68,6 +73,12 @@ export interface CredentialUploadResponse {
68
73
  entries: AuthCredentialSnapshotEntry[];
69
74
  }
70
75
 
76
+ export interface CredentialIfAbsentUploadResponse {
77
+ inserted: boolean;
78
+ reason: AuthCredentialIfAbsentReason;
79
+ entries: AuthCredentialSnapshotEntry[];
80
+ }
81
+
71
82
  /**
72
83
  * SSE event kinds emitted on `GET /v1/snapshot/stream`. The same value is set
73
84
  * as the SSE `event:` name (load-bearing for clients) **and** embedded as a
@@ -185,6 +185,15 @@ export const credentialDisableResponseSchema = z
185
185
  .strict();
186
186
 
187
187
  // ─── Upload ────────────────────────────────────────────────────────────────
188
+ const credentialIfAbsentReasonSchema = z.enum([
189
+ "inserted",
190
+ "skipped-existing",
191
+ "skipped-existing-runtime",
192
+ "skipped-existing-config",
193
+ "skipped-existing-env",
194
+ "skipped-existing-fallback",
195
+ "skipped-invalid",
196
+ ]);
188
197
 
189
198
  export const credentialUploadRequestSchema = z
190
199
  .object({
@@ -198,3 +207,11 @@ export const credentialUploadResponseSchema = z
198
207
  entries: z.array(credentialSnapshotEntrySchema),
199
208
  })
200
209
  .strict();
210
+
211
+ export const credentialIfAbsentUploadResponseSchema = z
212
+ .object({
213
+ inserted: z.boolean(),
214
+ reason: credentialIfAbsentReasonSchema,
215
+ entries: z.array(credentialSnapshotEntrySchema),
216
+ })
217
+ .strict();