@sayknow-cli/ai 0.2.7 → 0.3.1
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 +18 -0
- package/dist/types/auth-broker/client.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +3 -1
- package/dist/types/auth-broker/types.d.ts +6 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +30 -0
- package/dist/types/auth-storage.d.ts +17 -0
- package/dist/types/provider-models/special.d.ts +3 -0
- package/dist/types/providers/anthropic.d.ts +15 -0
- package/dist/types/providers/mock.d.ts +2 -0
- package/dist/types/providers/openai-responses-shared.d.ts +16 -1
- package/dist/types/types.d.ts +9 -1
- package/dist/types/utils/json-parse.d.ts +8 -0
- package/dist/types/utils/oauth/glm-zcode.d.ts +71 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/package.json +2 -2
- package/src/auth-broker/client.ts +15 -0
- package/src/auth-broker/remote-store.ts +20 -0
- package/src/auth-broker/server.ts +27 -0
- package/src/auth-broker/types.ts +12 -1
- package/src/auth-broker/wire-schemas.ts +17 -0
- package/src/auth-storage.ts +129 -11
- package/src/cli.ts +1 -0
- package/src/model-thinking.ts +11 -10
- package/src/models.json +58 -7
- package/src/provider-models/descriptors.ts +7 -1
- package/src/provider-models/openai-compat.ts +6 -0
- package/src/provider-models/special.ts +12 -0
- package/src/providers/anthropic.ts +82 -2
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-codex-responses.ts +14 -2
- package/src/providers/openai-completions.ts +12 -1
- package/src/providers/openai-responses-shared.ts +43 -1
- package/src/stream.ts +1 -0
- package/src/types.ts +9 -0
- package/src/utils/json-parse.ts +18 -0
- package/src/utils/oauth/glm-zcode.ts +433 -0
- package/src/utils/oauth/index.ts +10 -0
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils.ts +11 -2
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "../types";
|
|
31
31
|
import { normalizeResponsesToolCallId } from "../utils";
|
|
32
32
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
|
33
|
-
import { parseStreamingJson } from "../utils/json-parse";
|
|
33
|
+
import { isCompleteJson, parseStreamingJson } from "../utils/json-parse";
|
|
34
34
|
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
|
|
35
35
|
|
|
36
36
|
export function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
|
|
@@ -710,6 +710,13 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
710
710
|
: "Unknown error (no error details in response)";
|
|
711
711
|
throw new Error(message);
|
|
712
712
|
}
|
|
713
|
+
// A response cut short for length (`incomplete`) may have stopped
|
|
714
|
+
// mid-tool-call. Any tool-call item still tracked in `items` never
|
|
715
|
+
// received its terminal `output_item.done`, so it was cut off; flag it
|
|
716
|
+
// (along with any finalized-but-unparseable JSON call) so the agent loop
|
|
717
|
+
// rejects it instead of executing repaired/partial arguments.
|
|
718
|
+
const openBlocks = new Set<unknown>(Array.from(items.values(), entry => entry.block));
|
|
719
|
+
flagTruncatedToolCalls(output, output.stopReason, block => !openBlocks.has(block));
|
|
713
720
|
if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
|
|
714
721
|
output.stopReason = "toolUse";
|
|
715
722
|
}
|
|
@@ -728,6 +735,41 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
728
735
|
}
|
|
729
736
|
}
|
|
730
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Mark tool-call blocks left incomplete by a length-truncated response so the
|
|
740
|
+
* agent loop rejects them instead of executing a best-effort partial parse.
|
|
741
|
+
*
|
|
742
|
+
* The universal signal is finalization: a call that never received its terminal
|
|
743
|
+
* `output_item.done` (passed in via `isFinalized`) was cut off mid-arguments.
|
|
744
|
+
* This covers both JSON function calls and raw-input custom tools without
|
|
745
|
+
* mis-flagging a *completed* custom tool whose raw input is not valid JSON. As a
|
|
746
|
+
* defensive secondary, a finalized JSON function call whose buffered arguments
|
|
747
|
+
* still don't parse (e.g. a misbehaving relay) is flagged too. No-op unless the
|
|
748
|
+
* turn stopped for length.
|
|
749
|
+
*
|
|
750
|
+
* Shared by both Responses providers (`openai-responses`, `openai-codex-responses`).
|
|
751
|
+
*/
|
|
752
|
+
export function flagTruncatedToolCalls(
|
|
753
|
+
output: AssistantMessage,
|
|
754
|
+
stopReason: StopReason,
|
|
755
|
+
isFinalized: (block: ToolCall) => boolean,
|
|
756
|
+
): void {
|
|
757
|
+
if (stopReason !== "length") return;
|
|
758
|
+
for (const block of output.content) {
|
|
759
|
+
if (block.type !== "toolCall") continue;
|
|
760
|
+
if (!isFinalized(block)) {
|
|
761
|
+
block.incompleteArguments = true;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
// Finalized: custom tools carry raw (non-JSON) input and are complete once
|
|
765
|
+
// finalized; only JSON function calls get the parse double-check.
|
|
766
|
+
if (!block.customWireName) {
|
|
767
|
+
const partial = (block as { partialJson?: string }).partialJson;
|
|
768
|
+
if (partial !== undefined && !isCompleteJson(partial)) block.incompleteArguments = true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
731
773
|
export function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason {
|
|
732
774
|
if (!status) return "stop";
|
|
733
775
|
switch (status) {
|
package/src/stream.ts
CHANGED
|
@@ -88,6 +88,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
|
|
88
88
|
kilo: "KILO_API_KEY",
|
|
89
89
|
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
|
90
90
|
zai: "ZAI_API_KEY",
|
|
91
|
+
"glm-zcode": "GLM_ZCODE_API_KEY",
|
|
91
92
|
mistral: "MISTRAL_API_KEY",
|
|
92
93
|
minimax: "MINIMAX_API_KEY",
|
|
93
94
|
"minimax-code": "MINIMAX_CODE_API_KEY",
|
package/src/types.ts
CHANGED
|
@@ -122,6 +122,7 @@ export type KnownProvider =
|
|
|
122
122
|
| "kilo"
|
|
123
123
|
| "vercel-ai-gateway"
|
|
124
124
|
| "zai"
|
|
125
|
+
| "glm-zcode"
|
|
125
126
|
| "mistral"
|
|
126
127
|
| "minimax"
|
|
127
128
|
| "opencode-go"
|
|
@@ -489,6 +490,14 @@ export interface ToolCall {
|
|
|
489
490
|
* JSON function tools.
|
|
490
491
|
*/
|
|
491
492
|
customWireName?: string;
|
|
493
|
+
/**
|
|
494
|
+
* Set when the provider detected the argument JSON was truncated — the model
|
|
495
|
+
* hit its output-token limit (or the response was otherwise cut short) before
|
|
496
|
+
* emitting a complete arguments object. The `arguments` field then holds a
|
|
497
|
+
* best-effort partial parse and must not be executed as-is; the agent loop
|
|
498
|
+
* rejects the call with a retryable error instead.
|
|
499
|
+
*/
|
|
500
|
+
incompleteArguments?: boolean;
|
|
492
501
|
}
|
|
493
502
|
|
|
494
503
|
export interface Usage {
|
package/src/utils/json-parse.ts
CHANGED
|
@@ -146,3 +146,21 @@ export function parseStreamingJson<T = Record<string, unknown>>(partialJson: str
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a string is a complete, well-formed JSON document (strict parse, no
|
|
152
|
+
* repair). Used to distinguish a tool-call argument blob that finished cleanly
|
|
153
|
+
* from one that was cut off mid-stream (truncation). An empty / whitespace-only
|
|
154
|
+
* string is treated as complete: a tool invoked with no arguments legitimately
|
|
155
|
+
* streams an empty buffer and must not be flagged as truncated.
|
|
156
|
+
*/
|
|
157
|
+
export function isCompleteJson(text: string | undefined): boolean {
|
|
158
|
+
const trimmed = text?.trim();
|
|
159
|
+
if (!trimmed) return true;
|
|
160
|
+
try {
|
|
161
|
+
JSON.parse(trimmed);
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
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, type OAuthCallbackFlowOptions, parseCallbackInput } from "./callback-server";
|
|
32
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
33
|
+
|
|
34
|
+
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
|
|
35
|
+
export const GLM_ZCODE_REFRESH_SKEW_MS = 2 * 60 * 1000;
|
|
36
|
+
/** Provisioned API keys are long-lived; pin expiry far out so AuthStorage never force-refreshes. */
|
|
37
|
+
const GLM_ZCODE_API_KEY_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
|
38
|
+
/** Name ZCode gives the API key it auto-provisions (host bundle constant `FI`). */
|
|
39
|
+
const GLM_ZCODE_API_KEY_NAME = "zcode-api-key";
|
|
40
|
+
|
|
41
|
+
/** Default endpoints / client id. Override via the matching `ZCODE_OAUTH_*` env vars. */
|
|
42
|
+
export const GLM_ZCODE_OAUTH_AUTHORIZE_URL = "https://chat.z.ai/api/oauth/authorize";
|
|
43
|
+
export const GLM_ZCODE_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg";
|
|
44
|
+
export const GLM_ZCODE_OAUTH_REDIRECT_URI = "zcode://oauth/callback";
|
|
45
|
+
export const GLM_ZCODE_OAUTH_BROKER_TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token";
|
|
46
|
+
export const GLM_ZCODE_ZAI_LOGIN_URL = "https://api.z.ai/api/auth/z/login";
|
|
47
|
+
export const GLM_ZCODE_USERINFO_URL = "https://chat.z.ai/api/oauth/userinfo";
|
|
48
|
+
/** Z.AI business API base (customer/org/project/api-key management). */
|
|
49
|
+
export const GLM_ZCODE_ZAI_API_BASE = "https://api.z.ai";
|
|
50
|
+
/** Model API base — the provisioned key is used here, exactly like a dashboard key. */
|
|
51
|
+
export const GLM_ZCODE_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
|
|
52
|
+
|
|
53
|
+
type FetchImpl = typeof globalThis.fetch;
|
|
54
|
+
|
|
55
|
+
function envOr(name: string, fallback: string): string {
|
|
56
|
+
const value = process.env[name];
|
|
57
|
+
return value && value.trim().length > 0 ? value.trim() : fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveAuthorizeUrl(): string {
|
|
61
|
+
return envOr("ZCODE_OAUTH_AUTHORIZE_URL", GLM_ZCODE_OAUTH_AUTHORIZE_URL);
|
|
62
|
+
}
|
|
63
|
+
function resolveClientId(): string {
|
|
64
|
+
return envOr("ZCODE_OAUTH_CLIENT_ID", GLM_ZCODE_OAUTH_CLIENT_ID);
|
|
65
|
+
}
|
|
66
|
+
function resolveRedirectUri(): string {
|
|
67
|
+
return envOr("ZCODE_OAUTH_REDIRECT_URI", GLM_ZCODE_OAUTH_REDIRECT_URI);
|
|
68
|
+
}
|
|
69
|
+
function resolveBrokerTokenUrl(): string {
|
|
70
|
+
return envOr("ZCODE_OAUTH_BROKER_TOKEN_URL", GLM_ZCODE_OAUTH_BROKER_TOKEN_URL);
|
|
71
|
+
}
|
|
72
|
+
function resolveZaiLoginUrl(): string {
|
|
73
|
+
return envOr("ZCODE_OAUTH_ZAI_LOGIN_URL", GLM_ZCODE_ZAI_LOGIN_URL);
|
|
74
|
+
}
|
|
75
|
+
function resolveUserinfoUrl(): string {
|
|
76
|
+
return envOr("ZCODE_OAUTH_USERINFO_URL", GLM_ZCODE_USERINFO_URL);
|
|
77
|
+
}
|
|
78
|
+
function resolveZaiApiBase(): string {
|
|
79
|
+
return envOr("ZCODE_OAUTH_ZAI_API_BASE", GLM_ZCODE_ZAI_API_BASE).replace(/\/+$/, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Configured whenever a client id is available; the real ZCode client id ships as default. */
|
|
83
|
+
export function isGlmZcodeOAuthConfigured(): boolean {
|
|
84
|
+
return resolveClientId().length > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Mask token-like substrings so broker/upstream/business tokens never leak into errors. */
|
|
88
|
+
function redactSecrets(text: string): string {
|
|
89
|
+
return text
|
|
90
|
+
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted-jwt]")
|
|
91
|
+
.replace(/[A-Za-z0-9_-]{40,}/g, "[redacted]");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateHttpsEndpoint(rawUrl: string, label: string): string {
|
|
95
|
+
let parsed: URL;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(rawUrl);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`GLM ZCode ${label} endpoint is not a valid URL`);
|
|
100
|
+
}
|
|
101
|
+
if (parsed.protocol !== "https:") {
|
|
102
|
+
throw new Error(`GLM ZCode ${label} endpoint must use https`);
|
|
103
|
+
}
|
|
104
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function requestSignal(signal: AbortSignal | undefined): AbortSignal {
|
|
108
|
+
const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
|
109
|
+
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
113
|
+
return typeof value === "object" && value !== null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function postJson(
|
|
117
|
+
fetchImpl: FetchImpl,
|
|
118
|
+
url: string,
|
|
119
|
+
body: Record<string, unknown>,
|
|
120
|
+
label: string,
|
|
121
|
+
signal: AbortSignal | undefined,
|
|
122
|
+
bearer?: string,
|
|
123
|
+
): Promise<unknown> {
|
|
124
|
+
const headers: Record<string, string> = { Accept: "application/json", "Content-Type": "application/json" };
|
|
125
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
126
|
+
const response = await fetchImpl(url, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers,
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
signal: requestSignal(signal),
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`GLM ZCode ${label} request failed: ${response.status} ${redactSecrets(await response.text())}`);
|
|
134
|
+
}
|
|
135
|
+
return response.json();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function getJson(
|
|
139
|
+
fetchImpl: FetchImpl,
|
|
140
|
+
url: string,
|
|
141
|
+
bearer: string,
|
|
142
|
+
label: string,
|
|
143
|
+
signal: AbortSignal | undefined,
|
|
144
|
+
): Promise<unknown> {
|
|
145
|
+
const response = await fetchImpl(url, {
|
|
146
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
|
147
|
+
signal: requestSignal(signal),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error(`GLM ZCode ${label} request failed: ${response.status} ${redactSecrets(await response.text())}`);
|
|
151
|
+
}
|
|
152
|
+
return response.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface JwtPayload {
|
|
156
|
+
sub?: unknown;
|
|
157
|
+
email?: unknown;
|
|
158
|
+
[key: string]: unknown;
|
|
159
|
+
}
|
|
160
|
+
function decodeJwtPayload(token: string): JwtPayload | undefined {
|
|
161
|
+
const parts = token.split(".");
|
|
162
|
+
const payload = parts[1];
|
|
163
|
+
if (parts.length !== 3 || !payload) return undefined;
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as JwtPayload;
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface Identity {
|
|
172
|
+
email?: string;
|
|
173
|
+
accountId?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseBrokerResponse(payload: unknown): { upstreamZaiAccess: string; zcodeToken: string } {
|
|
177
|
+
const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined;
|
|
178
|
+
const zcodeToken = data && typeof data.token === "string" ? data.token : undefined;
|
|
179
|
+
const zai = data && isRecord(data.zai) ? data.zai : undefined;
|
|
180
|
+
const upstreamZaiAccess = zai && typeof zai.access_token === "string" ? zai.access_token : undefined;
|
|
181
|
+
if (!zcodeToken || !upstreamZaiAccess) {
|
|
182
|
+
throw new Error("GLM ZCode broker response missing data.token or data.zai.access_token");
|
|
183
|
+
}
|
|
184
|
+
return { upstreamZaiAccess, zcodeToken };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function resolveBusinessToken(
|
|
188
|
+
fetchImpl: FetchImpl,
|
|
189
|
+
upstreamZaiAccess: string,
|
|
190
|
+
signal: AbortSignal | undefined,
|
|
191
|
+
): Promise<string> {
|
|
192
|
+
const zaiLoginUrl = validateHttpsEndpoint(resolveZaiLoginUrl(), "z/login");
|
|
193
|
+
const payload = await postJson(fetchImpl, `${zaiLoginUrl}`, { token: upstreamZaiAccess }, "z/login", signal);
|
|
194
|
+
const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined;
|
|
195
|
+
const access = data && typeof data.access_token === "string" ? data.access_token : undefined;
|
|
196
|
+
if (!access) throw new Error("GLM ZCode z/login response missing data.access_token");
|
|
197
|
+
return access;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface OrgProject {
|
|
201
|
+
organizationId: string;
|
|
202
|
+
projectId: string;
|
|
203
|
+
email?: string;
|
|
204
|
+
accountId?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function pickDefaultOrgProject(customerInfo: unknown): OrgProject {
|
|
208
|
+
const data = isRecord(customerInfo) && isRecord(customerInfo.data) ? customerInfo.data : customerInfo;
|
|
209
|
+
const root = isRecord(data) ? data : {};
|
|
210
|
+
const orgs = Array.isArray(root.organizations) ? root.organizations : [];
|
|
211
|
+
const org = (orgs.find(o => isRecord(o) && o.isDefault) ?? orgs[0]) as Record<string, unknown> | undefined;
|
|
212
|
+
const organizationId = org && typeof org.organizationId === "string" ? org.organizationId : undefined;
|
|
213
|
+
const projects = org && Array.isArray(org.projects) ? org.projects : [];
|
|
214
|
+
const proj = (projects.find(p => isRecord(p) && p.isDefault) ?? projects[0]) as Record<string, unknown> | undefined;
|
|
215
|
+
const projectId = proj && typeof proj.projectId === "string" ? proj.projectId : undefined;
|
|
216
|
+
if (!organizationId || !projectId) {
|
|
217
|
+
throw new Error("GLM ZCode getCustomerInfo response missing default organization/project");
|
|
218
|
+
}
|
|
219
|
+
const email = typeof root.email === "string" && root.email.length > 0 ? root.email.toLowerCase() : undefined;
|
|
220
|
+
const accountId = typeof root.id === "string" ? root.id : typeof root.id === "number" ? String(root.id) : undefined;
|
|
221
|
+
return { organizationId, projectId, email, accountId };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Provision (or reuse) a Z.AI API key named "zcode-api-key" using the business token,
|
|
226
|
+
* mirroring ZCode's `resolveBizApiKey`. Returns "{apiKeyId}.{secretKey}".
|
|
227
|
+
*/
|
|
228
|
+
async function provisionZaiApiKey(
|
|
229
|
+
fetchImpl: FetchImpl,
|
|
230
|
+
businessToken: string,
|
|
231
|
+
signal: AbortSignal | undefined,
|
|
232
|
+
): Promise<{ apiKey: string; identity: Identity }> {
|
|
233
|
+
const apiBase = resolveZaiApiBase();
|
|
234
|
+
const customerInfo = await getJson(
|
|
235
|
+
fetchImpl,
|
|
236
|
+
`${apiBase}/api/biz/customer/getCustomerInfo`,
|
|
237
|
+
businessToken,
|
|
238
|
+
"getCustomerInfo",
|
|
239
|
+
signal,
|
|
240
|
+
);
|
|
241
|
+
const { organizationId, projectId, email, accountId } = pickDefaultOrgProject(customerInfo);
|
|
242
|
+
const keysUrl = `${apiBase}/api/biz/v1/organization/${organizationId}/projects/${projectId}/api_keys`;
|
|
243
|
+
|
|
244
|
+
const listPayload = await getJson(fetchImpl, keysUrl, businessToken, "api_keys.list", signal);
|
|
245
|
+
const listData = isRecord(listPayload) && Array.isArray(listPayload.data) ? listPayload.data : [];
|
|
246
|
+
let entry = listData.find(k => isRecord(k) && k.name === GLM_ZCODE_API_KEY_NAME) as
|
|
247
|
+
| Record<string, unknown>
|
|
248
|
+
| undefined;
|
|
249
|
+
|
|
250
|
+
if (!entry) {
|
|
251
|
+
const created = await postJson(
|
|
252
|
+
fetchImpl,
|
|
253
|
+
keysUrl,
|
|
254
|
+
{ name: GLM_ZCODE_API_KEY_NAME },
|
|
255
|
+
"api_keys.create",
|
|
256
|
+
signal,
|
|
257
|
+
businessToken,
|
|
258
|
+
);
|
|
259
|
+
entry = (isRecord(created) && isRecord(created.data) ? created.data : created) as Record<string, unknown>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const apiKeyId =
|
|
263
|
+
typeof entry.apiKey === "string" ? entry.apiKey.trim() : typeof entry.id === "string" ? entry.id : "";
|
|
264
|
+
if (!apiKeyId) throw new Error("GLM ZCode api_keys response missing apiKey id");
|
|
265
|
+
|
|
266
|
+
const copyPayload = await getJson(
|
|
267
|
+
fetchImpl,
|
|
268
|
+
`${keysUrl}/copy/${encodeURIComponent(apiKeyId)}`,
|
|
269
|
+
businessToken,
|
|
270
|
+
"api_keys.copy",
|
|
271
|
+
signal,
|
|
272
|
+
);
|
|
273
|
+
const copyData = isRecord(copyPayload) && isRecord(copyPayload.data) ? copyPayload.data : copyPayload;
|
|
274
|
+
const secretKey = isRecord(copyData) && typeof copyData.secretKey === "string" ? copyData.secretKey.trim() : "";
|
|
275
|
+
if (!secretKey) throw new Error("GLM ZCode api_keys copy response missing secretKey");
|
|
276
|
+
|
|
277
|
+
return { apiKey: `${apiKeyId}.${secretKey}`, identity: { email, accountId } };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function resolveIdentity(
|
|
281
|
+
fetchImpl: FetchImpl,
|
|
282
|
+
upstreamZaiAccess: string,
|
|
283
|
+
fallback: Identity,
|
|
284
|
+
jwtCandidates: readonly string[],
|
|
285
|
+
signal: AbortSignal | undefined,
|
|
286
|
+
): Promise<Identity> {
|
|
287
|
+
if (fallback.email || fallback.accountId) return fallback;
|
|
288
|
+
try {
|
|
289
|
+
const userinfoUrl = validateHttpsEndpoint(resolveUserinfoUrl(), "userinfo");
|
|
290
|
+
const response = await fetchImpl(userinfoUrl, {
|
|
291
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${upstreamZaiAccess}` },
|
|
292
|
+
signal: requestSignal(signal),
|
|
293
|
+
});
|
|
294
|
+
if (response.ok) {
|
|
295
|
+
const payload = (await response.json()) as unknown;
|
|
296
|
+
const data = isRecord(payload) && isRecord(payload.data) ? payload.data : isRecord(payload) ? payload : {};
|
|
297
|
+
const email = typeof data.email === "string" && data.email.length > 0 ? data.email.toLowerCase() : undefined;
|
|
298
|
+
const accountId =
|
|
299
|
+
(typeof data.id === "string" && data.id) || (typeof data.sub === "string" && data.sub) || undefined;
|
|
300
|
+
if (email || accountId) return { email, accountId: accountId || undefined };
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
// fall through
|
|
304
|
+
}
|
|
305
|
+
for (const token of jwtCandidates) {
|
|
306
|
+
const p = decodeJwtPayload(token);
|
|
307
|
+
const accountId = p && typeof p.sub === "string" ? p.sub : undefined;
|
|
308
|
+
const email = p && typeof p.email === "string" ? p.email.toLowerCase() : undefined;
|
|
309
|
+
if (accountId || email) return { accountId, email };
|
|
310
|
+
}
|
|
311
|
+
return {};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function credentialsFromApiKey(apiKey: string, upstreamZaiAccess: string, identity: Identity): OAuthCredentials {
|
|
315
|
+
return {
|
|
316
|
+
access: apiKey,
|
|
317
|
+
refresh: upstreamZaiAccess,
|
|
318
|
+
expires: Date.now() + GLM_ZCODE_API_KEY_TTL_MS,
|
|
319
|
+
email: identity.email,
|
|
320
|
+
accountId: identity.accountId,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function provisionFromUpstream(
|
|
325
|
+
fetchImpl: FetchImpl,
|
|
326
|
+
upstreamZaiAccess: string,
|
|
327
|
+
zcodeTokenForIdentity: string | undefined,
|
|
328
|
+
signal: AbortSignal | undefined,
|
|
329
|
+
): Promise<OAuthCredentials> {
|
|
330
|
+
const businessToken = await resolveBusinessToken(fetchImpl, upstreamZaiAccess, signal);
|
|
331
|
+
const { apiKey, identity: keyIdentity } = await provisionZaiApiKey(fetchImpl, businessToken, signal);
|
|
332
|
+
const identity = await resolveIdentity(
|
|
333
|
+
fetchImpl,
|
|
334
|
+
upstreamZaiAccess,
|
|
335
|
+
keyIdentity,
|
|
336
|
+
[zcodeTokenForIdentity ?? "", businessToken].filter(Boolean),
|
|
337
|
+
signal,
|
|
338
|
+
);
|
|
339
|
+
return credentialsFromApiKey(apiKey, upstreamZaiAccess, identity);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function exchangeGlmZcodeCode(
|
|
343
|
+
fetchImpl: FetchImpl,
|
|
344
|
+
input: { code: string; state: string; redirectUri: string },
|
|
345
|
+
signal: AbortSignal | undefined,
|
|
346
|
+
): Promise<OAuthCredentials> {
|
|
347
|
+
const parsed = parseCallbackInput(input.code);
|
|
348
|
+
const code = parsed.code ?? input.code;
|
|
349
|
+
const brokerUrl = validateHttpsEndpoint(resolveBrokerTokenUrl(), "broker");
|
|
350
|
+
const brokerPayload = await postJson(
|
|
351
|
+
fetchImpl,
|
|
352
|
+
brokerUrl,
|
|
353
|
+
{ provider: "zai", code, redirect_uri: input.redirectUri, state: input.state },
|
|
354
|
+
"broker",
|
|
355
|
+
signal,
|
|
356
|
+
);
|
|
357
|
+
const { upstreamZaiAccess, zcodeToken } = parseBrokerResponse(brokerPayload);
|
|
358
|
+
return provisionFromUpstream(fetchImpl, upstreamZaiAccess, zcodeToken, signal);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface GlmZcodeOAuthFlowOptions {
|
|
362
|
+
fetch?: FetchImpl;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export class GlmZcodeOAuthFlow extends OAuthCallbackFlow {
|
|
366
|
+
#fetch: FetchImpl;
|
|
367
|
+
|
|
368
|
+
constructor(ctrl: OAuthController, options: GlmZcodeOAuthFlowOptions = {}) {
|
|
369
|
+
super(ctrl, {
|
|
370
|
+
preferredPort: 0,
|
|
371
|
+
callbackPath: "/callback",
|
|
372
|
+
callbackHostname: "127.0.0.1",
|
|
373
|
+
callbackBindHostname: "127.0.0.1",
|
|
374
|
+
redirectUri: resolveRedirectUri(),
|
|
375
|
+
} satisfies OAuthCallbackFlowOptions);
|
|
376
|
+
this.#fetch = options.fetch ?? ctrl.fetch ?? globalThis.fetch;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
380
|
+
const authorizeUrl = validateHttpsEndpoint(resolveAuthorizeUrl(), "authorize");
|
|
381
|
+
const params = new URLSearchParams({
|
|
382
|
+
redirect_uri: redirectUri,
|
|
383
|
+
response_type: "code",
|
|
384
|
+
client_id: resolveClientId(),
|
|
385
|
+
state,
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
url: `${authorizeUrl}?${params.toString()}`,
|
|
389
|
+
instructions:
|
|
390
|
+
"Complete Z.AI login in your browser. This is an UNOFFICIAL ZCode-based login — use at your own risk; it may stop working or violate ZCode/Z.AI Terms of Service. Because this CLI cannot receive the zcode:// redirect, paste the final redirect URL or authorization code when prompted.",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
395
|
+
return exchangeGlmZcodeCode(this.#fetch, { code, state, redirectUri }, this.ctrl.signal);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function loginGlmZcode(
|
|
400
|
+
ctrl: OAuthController,
|
|
401
|
+
options?: GlmZcodeOAuthFlowOptions,
|
|
402
|
+
): Promise<OAuthCredentials> {
|
|
403
|
+
return new GlmZcodeOAuthFlow(ctrl, options).login();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface GlmZcodeRefreshOptions {
|
|
407
|
+
signal?: AbortSignal;
|
|
408
|
+
fetch?: FetchImpl;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Re-provision the Z.AI API key from the stored upstream token. The key itself is
|
|
413
|
+
* long-lived, so this is rarely needed; if the upstream token has expired it fails
|
|
414
|
+
* loudly and the user must re-login.
|
|
415
|
+
*/
|
|
416
|
+
export async function refreshGlmZcodeToken(
|
|
417
|
+
credentials: OAuthCredentials,
|
|
418
|
+
options: AbortSignal | GlmZcodeRefreshOptions = {},
|
|
419
|
+
): Promise<OAuthCredentials> {
|
|
420
|
+
const { signal, fetch: fetchImpl } =
|
|
421
|
+
options instanceof AbortSignal ? { signal: options, fetch: undefined } : options;
|
|
422
|
+
const upstream = credentials.refresh;
|
|
423
|
+
if (!upstream) {
|
|
424
|
+
throw new Error("glm-zcode credentials require re-login (`/login glm-zcode`); no stored upstream Z.AI token");
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
return await provisionFromUpstream(fetchImpl ?? globalThis.fetch, upstream, undefined, signal);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`glm-zcode credentials require re-login (\`/login glm-zcode\`); re-provisioning the Z.AI API key failed (${redactSecrets(String(error))})`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -170,6 +170,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
|
|
|
170
170
|
name: "Z.AI (GLM Coding Plan)",
|
|
171
171
|
available: true,
|
|
172
172
|
},
|
|
173
|
+
{
|
|
174
|
+
id: "glm-zcode",
|
|
175
|
+
name: "GLM ZCode OAuth (unofficial, opt-in)",
|
|
176
|
+
available: true,
|
|
177
|
+
},
|
|
173
178
|
{
|
|
174
179
|
id: "minimax-code",
|
|
175
180
|
name: "MiniMax Coding Plan (International)",
|
|
@@ -335,6 +340,11 @@ export async function refreshOAuthToken(
|
|
|
335
340
|
newCredentials = await refreshXaiToken(credentials.refresh);
|
|
336
341
|
break;
|
|
337
342
|
}
|
|
343
|
+
case "glm-zcode": {
|
|
344
|
+
const { refreshGlmZcodeToken } = await import("./glm-zcode");
|
|
345
|
+
newCredentials = await refreshGlmZcodeToken(credentials);
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
338
348
|
case "kilo":
|
|
339
349
|
case "perplexity":
|
|
340
350
|
case "huggingface":
|
package/src/utils/oauth/types.ts
CHANGED
package/src/utils.ts
CHANGED
|
@@ -98,8 +98,10 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
|
98
98
|
): OpenAIResponsesReplayItem | undefined {
|
|
99
99
|
if (item.type === "item_reference") return undefined;
|
|
100
100
|
|
|
101
|
-
// providerPayload stores raw output items; replay strips
|
|
102
|
-
const { id: _id, ...
|
|
101
|
+
// providerPayload stores raw output items; replay strips fields that are output-only.
|
|
102
|
+
const { id: _id, ...itemWithoutId } = item;
|
|
103
|
+
const sanitizedItem =
|
|
104
|
+
item.type === "computer_call" ? sanitizeComputerCallForResponsesInput(itemWithoutId) : itemWithoutId;
|
|
103
105
|
if (typeof item.call_id === "string") {
|
|
104
106
|
sanitizedItem.call_id = normalizeReplayedResponsesHistoryCallId(item.call_id, normalizedCallIds);
|
|
105
107
|
}
|
|
@@ -107,6 +109,13 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
|
107
109
|
return sanitizedItem as unknown as OpenAIResponsesReplayItem;
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
function sanitizeComputerCallForResponsesInput(item: Record<string, unknown>): Record<string, unknown> {
|
|
113
|
+
// The Responses stream includes the performed computer action on output items,
|
|
114
|
+
// but the create input accepts only the call identity/status fields on replay.
|
|
115
|
+
const { action: _action, actions: _actions, ...inputSafeItem } = item;
|
|
116
|
+
return inputSafeItem;
|
|
117
|
+
}
|
|
118
|
+
|
|
110
119
|
function normalizeReplayedResponsesHistoryCallId(value: string, normalizedValues: Map<string, string>): string {
|
|
111
120
|
const normalized = normalizedValues.get(value);
|
|
112
121
|
if (normalized) return normalized;
|