@mafdet/cli 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.
Files changed (107) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -0
  4. package/README.zh-CN.md +148 -0
  5. package/dist/agent/approvals.d.ts +38 -0
  6. package/dist/agent/approvals.d.ts.map +1 -0
  7. package/dist/agent/approvals.js +53 -0
  8. package/dist/agent/command-policy.d.ts +36 -0
  9. package/dist/agent/command-policy.d.ts.map +1 -0
  10. package/dist/agent/command-policy.js +133 -0
  11. package/dist/agent/loop.d.ts +107 -0
  12. package/dist/agent/loop.d.ts.map +1 -0
  13. package/dist/agent/loop.js +242 -0
  14. package/dist/agent/session.d.ts +62 -0
  15. package/dist/agent/session.d.ts.map +1 -0
  16. package/dist/agent/session.js +290 -0
  17. package/dist/agent/tools/read-only.d.ts +15 -0
  18. package/dist/agent/tools/read-only.d.ts.map +1 -0
  19. package/dist/agent/tools/read-only.js +254 -0
  20. package/dist/agent/tools/run-command.d.ts +20 -0
  21. package/dist/agent/tools/run-command.d.ts.map +1 -0
  22. package/dist/agent/tools/run-command.js +116 -0
  23. package/dist/agent/tools/types.d.ts +46 -0
  24. package/dist/agent/tools/types.d.ts.map +1 -0
  25. package/dist/agent/tools/types.js +16 -0
  26. package/dist/agent/tools/write.d.ts +3 -0
  27. package/dist/agent/tools/write.d.ts.map +1 -0
  28. package/dist/agent/tools/write.js +68 -0
  29. package/dist/api/account.d.ts +39 -0
  30. package/dist/api/account.d.ts.map +1 -0
  31. package/dist/api/account.js +39 -0
  32. package/dist/api/errors.d.ts +38 -0
  33. package/dist/api/errors.d.ts.map +1 -0
  34. package/dist/api/errors.js +149 -0
  35. package/dist/api/http.d.ts +63 -0
  36. package/dist/api/http.d.ts.map +1 -0
  37. package/dist/api/http.js +207 -0
  38. package/dist/api/models.d.ts +68 -0
  39. package/dist/api/models.d.ts.map +1 -0
  40. package/dist/api/models.js +91 -0
  41. package/dist/api/sse-parser.d.ts +72 -0
  42. package/dist/api/sse-parser.d.ts.map +1 -0
  43. package/dist/api/sse-parser.js +163 -0
  44. package/dist/commands/account.d.ts +4 -0
  45. package/dist/commands/account.d.ts.map +1 -0
  46. package/dist/commands/account.js +103 -0
  47. package/dist/commands/agent.d.ts +10 -0
  48. package/dist/commands/agent.d.ts.map +1 -0
  49. package/dist/commands/agent.js +268 -0
  50. package/dist/commands/chat.d.ts +13 -0
  51. package/dist/commands/chat.d.ts.map +1 -0
  52. package/dist/commands/chat.js +191 -0
  53. package/dist/commands/config.d.ts +15 -0
  54. package/dist/commands/config.d.ts.map +1 -0
  55. package/dist/commands/config.js +74 -0
  56. package/dist/commands/doctor.d.ts +15 -0
  57. package/dist/commands/doctor.d.ts.map +1 -0
  58. package/dist/commands/doctor.js +157 -0
  59. package/dist/commands/embed.d.ts +7 -0
  60. package/dist/commands/embed.d.ts.map +1 -0
  61. package/dist/commands/embed.js +105 -0
  62. package/dist/commands/models.d.ts +4 -0
  63. package/dist/commands/models.d.ts.map +1 -0
  64. package/dist/commands/models.js +151 -0
  65. package/dist/commands/runtime.d.ts +29 -0
  66. package/dist/commands/runtime.d.ts.map +1 -0
  67. package/dist/commands/runtime.js +56 -0
  68. package/dist/commands/speech.d.ts +7 -0
  69. package/dist/commands/speech.d.ts.map +1 -0
  70. package/dist/commands/speech.js +111 -0
  71. package/dist/config/config.d.ts +57 -0
  72. package/dist/config/config.d.ts.map +1 -0
  73. package/dist/config/config.js +129 -0
  74. package/dist/config/load.d.ts +20 -0
  75. package/dist/config/load.d.ts.map +1 -0
  76. package/dist/config/load.js +48 -0
  77. package/dist/config/paths.d.ts +11 -0
  78. package/dist/config/paths.d.ts.map +1 -0
  79. package/dist/config/paths.js +26 -0
  80. package/dist/filesystem/diff.d.ts +12 -0
  81. package/dist/filesystem/diff.d.ts.map +1 -0
  82. package/dist/filesystem/diff.js +109 -0
  83. package/dist/filesystem/sensitive-files.d.ts +19 -0
  84. package/dist/filesystem/sensitive-files.d.ts.map +1 -0
  85. package/dist/filesystem/sensitive-files.js +46 -0
  86. package/dist/filesystem/workspace.d.ts +92 -0
  87. package/dist/filesystem/workspace.d.ts.map +1 -0
  88. package/dist/filesystem/workspace.js +264 -0
  89. package/dist/index.d.ts +4 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +109 -0
  92. package/dist/output/exit-codes.d.ts +25 -0
  93. package/dist/output/exit-codes.d.ts.map +1 -0
  94. package/dist/output/exit-codes.js +23 -0
  95. package/dist/output/format.d.ts +28 -0
  96. package/dist/output/format.d.ts.map +1 -0
  97. package/dist/output/format.js +56 -0
  98. package/dist/output/money.d.ts +16 -0
  99. package/dist/output/money.d.ts.map +1 -0
  100. package/dist/output/money.js +35 -0
  101. package/dist/output/write-file.d.ts +11 -0
  102. package/dist/output/write-file.d.ts.map +1 -0
  103. package/dist/output/write-file.js +38 -0
  104. package/dist/security/redact.d.ts +16 -0
  105. package/dist/security/redact.d.ts.map +1 -0
  106. package/dist/security/redact.js +61 -0
  107. package/package.json +51 -0
@@ -0,0 +1,207 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ExitCode } from "../output/exit-codes.js";
3
+ import { ApiError, errorForResponse } from "./errors.js";
4
+ export const DEFAULT_TIMEOUT_MS = 120_000;
5
+ /** A JSON answer this large is a bug or an attack, not a model reply. */
6
+ export const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
7
+ /**
8
+ * The one place that talks to the network.
9
+ *
10
+ * It deliberately does NOT retry. Only an explicit 429 is even retryable, and
11
+ * the decision belongs to the caller that knows whether replaying is safe —
12
+ * a timeout or a 5xx may mean the model already started generating, and
13
+ * generation is already billed (plan §7.5).
14
+ */
15
+ export class HttpClient {
16
+ baseUrl;
17
+ apiKey;
18
+ userAgent;
19
+ timeoutMs;
20
+ maxBytes;
21
+ fetchImpl;
22
+ constructor(options) {
23
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
24
+ this.apiKey = options.apiKey;
25
+ this.userAgent = options.userAgent;
26
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
27
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
28
+ this.fetchImpl = options.fetchImpl ?? fetch;
29
+ }
30
+ async getJson(path, options = {}) {
31
+ const response = await this.send("GET", path, undefined, options);
32
+ return this.readJson(response, options);
33
+ }
34
+ async postJson(path, body, options = {}) {
35
+ const response = await this.send("POST", path, body, options);
36
+ return this.readJson(response, options);
37
+ }
38
+ /** For `/v1/audio/speech`, which answers with audio bytes. */
39
+ async postBinary(path, body, options = {}) {
40
+ const response = await this.send("POST", path, body, options);
41
+ const requestId = requestIdOf(response.headers);
42
+ if (!response.ok)
43
+ throw await this.errorFrom(response, requestId, options);
44
+ const bytes = await readCapped(response, options.maxBytes ?? this.maxBytes, requestId);
45
+ return {
46
+ data: bytes,
47
+ status: response.status,
48
+ headers: response.headers,
49
+ requestId,
50
+ };
51
+ }
52
+ /** Raw streaming response, for the SSE reader to consume. */
53
+ async postStream(path, body, options = {}) {
54
+ const response = await this.send("POST", path, body, options, {
55
+ accept: "text/event-stream",
56
+ });
57
+ const requestId = requestIdOf(response.headers);
58
+ if (!response.ok)
59
+ throw await this.errorFrom(response, requestId, options);
60
+ return { response, requestId };
61
+ }
62
+ async send(method, path, body, options, extraHeaders = {}) {
63
+ const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
64
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
65
+ const timeoutController = new AbortController();
66
+ const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
67
+ // Two independent reasons to stop, and they must stay distinguishable:
68
+ // "we gave up" and "the user pressed Ctrl+C" are different exit codes.
69
+ const signal = options.signal
70
+ ? AbortSignal.any([options.signal, timeoutController.signal])
71
+ : timeoutController.signal;
72
+ const headers = {
73
+ accept: "application/json",
74
+ "user-agent": this.userAgent,
75
+ "x-request-id": randomUUID(),
76
+ ...extraHeaders,
77
+ ...options.headers,
78
+ };
79
+ if (this.apiKey)
80
+ headers.authorization = `Bearer ${this.apiKey}`;
81
+ if (body !== undefined)
82
+ headers["content-type"] = "application/json";
83
+ try {
84
+ return await this.fetchImpl(url, {
85
+ method,
86
+ headers,
87
+ body: body === undefined ? undefined : JSON.stringify(body),
88
+ signal,
89
+ });
90
+ }
91
+ catch (error) {
92
+ if (options.signal?.aborted) {
93
+ throw new ApiError({
94
+ kind: "cancelled",
95
+ exitCode: ExitCode.Cancelled,
96
+ message: "cancelled",
97
+ });
98
+ }
99
+ if (timeoutController.signal.aborted) {
100
+ throw new ApiError({
101
+ kind: "timeout",
102
+ exitCode: ExitCode.Unavailable,
103
+ message: `request timed out after ${timeoutMs}ms`,
104
+ });
105
+ }
106
+ throw new ApiError({
107
+ kind: "network",
108
+ exitCode: ExitCode.Unavailable,
109
+ message: `network error: ${error.message}`,
110
+ });
111
+ }
112
+ finally {
113
+ clearTimeout(timer);
114
+ }
115
+ }
116
+ async readJson(response, options) {
117
+ const requestId = requestIdOf(response.headers);
118
+ const maxBytes = options.maxBytes ?? this.maxBytes;
119
+ const text = new TextDecoder().decode(await readCapped(response, maxBytes, requestId));
120
+ if (!response.ok) {
121
+ throw errorForResponse(response.status, safeParse(text), response.headers, requestId);
122
+ }
123
+ let data;
124
+ try {
125
+ data = JSON.parse(text);
126
+ }
127
+ catch {
128
+ throw new ApiError({
129
+ kind: "server",
130
+ exitCode: ExitCode.Unavailable,
131
+ status: response.status,
132
+ requestId,
133
+ message: "the API returned a body that is not valid JSON",
134
+ });
135
+ }
136
+ return {
137
+ data,
138
+ status: response.status,
139
+ headers: response.headers,
140
+ requestId,
141
+ };
142
+ }
143
+ async errorFrom(response, requestId, options) {
144
+ const text = new TextDecoder().decode(await readCapped(response, options.maxBytes ?? this.maxBytes, requestId));
145
+ return errorForResponse(response.status, safeParse(text), response.headers, requestId);
146
+ }
147
+ }
148
+ /**
149
+ * Read a body while counting bytes, so an endless or oversized response is cut
150
+ * off rather than filling memory. Content-Length is checked first when it is
151
+ * present, but the running count is what actually enforces the cap — a
152
+ * streaming body may not declare a length.
153
+ */
154
+ export async function readCapped(response, maxBytes, requestId) {
155
+ const declared = Number(response.headers.get("content-length"));
156
+ if (Number.isFinite(declared) && declared > maxBytes) {
157
+ throw tooLarge(maxBytes, requestId);
158
+ }
159
+ const body = response.body;
160
+ if (!body)
161
+ return new Uint8Array(0);
162
+ const reader = body.getReader();
163
+ const chunks = [];
164
+ let total = 0;
165
+ try {
166
+ for (;;) {
167
+ const { done, value } = await reader.read();
168
+ if (done)
169
+ break;
170
+ total += value.byteLength;
171
+ if (total > maxBytes) {
172
+ await reader.cancel();
173
+ throw tooLarge(maxBytes, requestId);
174
+ }
175
+ chunks.push(value);
176
+ }
177
+ }
178
+ finally {
179
+ reader.releaseLock();
180
+ }
181
+ const out = new Uint8Array(total);
182
+ let offset = 0;
183
+ for (const chunk of chunks) {
184
+ out.set(chunk, offset);
185
+ offset += chunk.byteLength;
186
+ }
187
+ return out;
188
+ }
189
+ function tooLarge(maxBytes, requestId) {
190
+ return new ApiError({
191
+ kind: "too_large",
192
+ exitCode: ExitCode.Unavailable,
193
+ requestId,
194
+ message: `response exceeded the ${maxBytes} byte limit`,
195
+ });
196
+ }
197
+ function requestIdOf(headers) {
198
+ return (headers.get("x-litellm-call-id") ?? headers.get("x-request-id") ?? undefined);
199
+ }
200
+ function safeParse(text) {
201
+ try {
202
+ return JSON.parse(text);
203
+ }
204
+ catch {
205
+ return { error: { message: text.slice(0, 500) } };
206
+ }
207
+ }
@@ -0,0 +1,68 @@
1
+ import type { HttpClient } from "./http.js";
2
+ /** What the data plane says: an id this key may call. Metadata is unreliable. */
3
+ export interface DataPlaneModel {
4
+ id: string;
5
+ }
6
+ /** What the catalogue says a model is. */
7
+ export interface CatalogModel {
8
+ modelId: string;
9
+ displayName?: string;
10
+ provider?: string;
11
+ capabilities?: string[];
12
+ apiSurface?: string;
13
+ contextLength?: number;
14
+ inputPricePer1mMicroUsd?: string;
15
+ outputPricePer1mMicroUsd?: string;
16
+ accessTier?: string;
17
+ deprecated?: boolean;
18
+ retires_at?: string | null;
19
+ replacement_model_id?: string | null;
20
+ }
21
+ export interface ResolvedModel extends CatalogModel {
22
+ agentEligible: boolean;
23
+ }
24
+ export interface ModelSets {
25
+ /** Callable AND described: what the CLI offers. */
26
+ available: ResolvedModel[];
27
+ /** Callable but undescribed — usable with an explicit --model, never by the agent. */
28
+ unlisted: string[];
29
+ /** Described but not callable with this key. */
30
+ inaccessible: string[];
31
+ }
32
+ /**
33
+ * Agent admission. Every condition is something the catalogue states outright;
34
+ * nothing is inferred from the model id, the provider or the display name —
35
+ * that inference is exactly what CLI-000C removed from the server.
36
+ */
37
+ export declare function isAgentEligible(model: CatalogModel | undefined): boolean;
38
+ /**
39
+ * Combine the two sources.
40
+ *
41
+ * data plane → proof of ACCESS for this key (the only surface that knows
42
+ * the key's allowlist), but its metadata is LiteLLM
43
+ * boilerplate: it reports a 1M context window for a 128K
44
+ * model and openai as the owner of a DeepSeek route.
45
+ * catalogue → the TRUTH about what each model is.
46
+ *
47
+ * Anything callable but undescribed stays usable, never agent-eligible:
48
+ * fail closed rather than guess (plan §4.2).
49
+ */
50
+ export declare function intersectModels(dataPlane: readonly DataPlaneModel[], catalog: readonly CatalogModel[]): ModelSets;
51
+ /** GET /v1/models — the key's access list. */
52
+ export declare function fetchAccessibleModels(client: HttpClient): Promise<DataPlaneModel[]>;
53
+ /** GET /models on the business API — public catalogue, no key required. */
54
+ export declare function fetchCatalog(client: HttpClient): Promise<CatalogModel[]>;
55
+ /**
56
+ * Check the model serves the endpoint the command needs, before spending a
57
+ * request on it. The gateway does answer 400 `model_api_surface_mismatch`, but
58
+ * it buries the code inside a stringified JSON message (CLI-001), and a local
59
+ * check turns that into a usage error the user can act on.
60
+ */
61
+ export declare function assertSurface(catalog: readonly CatalogModel[], modelId: string, expected: string): CatalogModel | undefined;
62
+ export declare class SurfaceMismatchError extends Error {
63
+ readonly modelId: string;
64
+ readonly actual: string;
65
+ readonly expected: string;
66
+ constructor(modelId: string, actual: string, expected: string);
67
+ }
68
+ //# sourceMappingURL=models.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/api/models.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,aAAc,SAAQ,YAAY;IACjD,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,mDAAmD;IACnD,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gDAAgD;IAChD,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,GAAG,OAAO,CAQxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,SAAS,CAuBX;AAED,8CAA8C;AAC9C,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,cAAc,EAAE,CAAC,CAG3B;AAED,2EAA2E;AAC3E,wBAAsB,YAAY,CAChC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAGzB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,YAAY,GAAG,SAAS,CAY1B;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,OAAO,EAAE,MAAM;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM;gBAFhB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM;CAO5B"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Agent admission. Every condition is something the catalogue states outright;
3
+ * nothing is inferred from the model id, the provider or the display name —
4
+ * that inference is exactly what CLI-000C removed from the server.
5
+ */
6
+ export function isAgentEligible(model) {
7
+ if (!model)
8
+ return false;
9
+ if (!(model.capabilities ?? []).includes("tool_use"))
10
+ return false;
11
+ if ((model.apiSurface ?? "CHAT_COMPLETIONS") !== "CHAT_COMPLETIONS")
12
+ return false;
13
+ // A model on its way out is not a new default; an explicit --model still works.
14
+ if (model.deprecated === true)
15
+ return false;
16
+ return true;
17
+ }
18
+ /**
19
+ * Combine the two sources.
20
+ *
21
+ * data plane → proof of ACCESS for this key (the only surface that knows
22
+ * the key's allowlist), but its metadata is LiteLLM
23
+ * boilerplate: it reports a 1M context window for a 128K
24
+ * model and openai as the owner of a DeepSeek route.
25
+ * catalogue → the TRUTH about what each model is.
26
+ *
27
+ * Anything callable but undescribed stays usable, never agent-eligible:
28
+ * fail closed rather than guess (plan §4.2).
29
+ */
30
+ export function intersectModels(dataPlane, catalog) {
31
+ const byId = new Map(catalog.map((model) => [model.modelId, model]));
32
+ const accessible = dataPlane.map((model) => model.id);
33
+ const available = [];
34
+ const unlisted = [];
35
+ for (const id of accessible) {
36
+ const meta = byId.get(id);
37
+ if (!meta) {
38
+ unlisted.push(id);
39
+ continue;
40
+ }
41
+ available.push({ ...meta, agentEligible: isAgentEligible(meta) });
42
+ }
43
+ return {
44
+ available: available.sort((a, b) => a.modelId.localeCompare(b.modelId)),
45
+ unlisted: unlisted.sort(),
46
+ inaccessible: [...byId.keys()]
47
+ .filter((id) => !accessible.includes(id))
48
+ .sort(),
49
+ };
50
+ }
51
+ /** GET /v1/models — the key's access list. */
52
+ export async function fetchAccessibleModels(client) {
53
+ const { data } = await client.getJson("/models");
54
+ return data.data ?? [];
55
+ }
56
+ /** GET /models on the business API — public catalogue, no key required. */
57
+ export async function fetchCatalog(client) {
58
+ const { data } = await client.getJson("/models");
59
+ return data.data ?? [];
60
+ }
61
+ /**
62
+ * Check the model serves the endpoint the command needs, before spending a
63
+ * request on it. The gateway does answer 400 `model_api_surface_mismatch`, but
64
+ * it buries the code inside a stringified JSON message (CLI-001), and a local
65
+ * check turns that into a usage error the user can act on.
66
+ */
67
+ export function assertSurface(catalog, modelId, expected) {
68
+ const model = catalog.find((m) => m.modelId === modelId);
69
+ // Unknown to the catalogue: let the request through rather than block a
70
+ // model the data plane may well accept (fail open for direct calls, closed
71
+ // only for agent admission).
72
+ if (!model)
73
+ return undefined;
74
+ const actual = model.apiSurface ?? "CHAT_COMPLETIONS";
75
+ if (actual !== expected) {
76
+ throw new SurfaceMismatchError(modelId, actual, expected);
77
+ }
78
+ return model;
79
+ }
80
+ export class SurfaceMismatchError extends Error {
81
+ modelId;
82
+ actual;
83
+ expected;
84
+ constructor(modelId, actual, expected) {
85
+ super(`${modelId} serves ${actual}, not ${expected} - pick a model for this command (mafdet models list --surface ...)`);
86
+ this.modelId = modelId;
87
+ this.actual = actual;
88
+ this.expected = expected;
89
+ this.name = "SurfaceMismatchError";
90
+ }
91
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * SSE reader for the chat data plane.
3
+ *
4
+ * Every rule here is a fact measured in Phase 0 against real transcripts from
5
+ * DeepSeek, Kimi, Qwen and Vertex Gemini (tests/fixtures/cli-000*), not an
6
+ * assumption about how streaming "usually" works:
7
+ *
8
+ * - `id` and `name` arrive exactly once, so tool calls merge by `index`.
9
+ * - Qwen sends `"id": ""` on continuation frames; `??` would overwrite the
10
+ * real id with an empty string and the tool result would be rejected.
11
+ * - Gemini packs a thought signature into the id (700+ chars) and delivers a
12
+ * whole call in one frame, where Kimi takes nine — fragment counts mean
13
+ * nothing.
14
+ * - `finish_reason` arrives in its own frame with an empty delta; `[DONE]`
15
+ * ends the stream.
16
+ * - With `stream_options.include_usage`, usage arrives AFTER finish_reason
17
+ * in a frame whose `choices` is non-empty but whose delta is empty. A
18
+ * parser that only reads usage when `choices` is empty reports zero cost.
19
+ */
20
+ export interface StreamedToolCall {
21
+ index: number;
22
+ id?: string;
23
+ name?: string;
24
+ /** Raw JSON text; parse only once the stream is complete. */
25
+ arguments: string;
26
+ }
27
+ export interface StreamUsage {
28
+ prompt_tokens: number;
29
+ completion_tokens: number;
30
+ total_tokens: number;
31
+ }
32
+ export interface StreamEvents {
33
+ /** Assistant text as it arrives. */
34
+ onText?: (delta: string) => void;
35
+ }
36
+ export interface StreamResult {
37
+ text: string;
38
+ toolCalls: StreamedToolCall[];
39
+ finishReason?: string;
40
+ usage?: StreamUsage;
41
+ done: boolean;
42
+ }
43
+ /** Split a byte stream into SSE `data:` payloads across arbitrary chunking. */
44
+ export declare class SseFramer {
45
+ private buffer;
46
+ private readonly decoder;
47
+ push(chunk: Uint8Array): string[];
48
+ /** Flush whatever a closed stream left behind. */
49
+ end(): string[];
50
+ private drain;
51
+ }
52
+ /** Accumulates frames into a final answer plus tool calls. */
53
+ export declare class ChatStreamAssembler {
54
+ private readonly events;
55
+ private readonly calls;
56
+ private text;
57
+ private finishReason?;
58
+ private usage?;
59
+ private done;
60
+ constructor(events?: StreamEvents);
61
+ /** @returns true when the stream is complete (`[DONE]`). */
62
+ push(payload: string): boolean;
63
+ result(): StreamResult;
64
+ }
65
+ /**
66
+ * Read a streaming response to completion.
67
+ *
68
+ * `signal` is the user's Ctrl+C: aborting stops reading AND closes the
69
+ * connection, which is what actually tells the gateway to stop generating.
70
+ */
71
+ export declare function readChatStream(response: Response, events?: StreamEvents, signal?: AbortSignal): Promise<StreamResult>;
72
+ //# sourceMappingURL=sse-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse-parser.d.ts","sourceRoot":"","sources":["../../src/api/sse-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;CACf;AAWD,+EAA+E;AAC/E,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAE7C,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,EAAE;IAMjC,kDAAkD;IAClD,GAAG,IAAI,MAAM,EAAE;IASf,OAAO,CAAC,KAAK;CAYd;AAED,8DAA8D;AAC9D,qBAAa,mBAAmB;IAOlB,OAAO,CAAC,QAAQ,CAAC,MAAM;IANnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;IAC7D,OAAO,CAAC,IAAI,CAAM;IAClB,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,KAAK,CAAC,CAAc;IAC5B,OAAO,CAAC,IAAI,CAAS;gBAEQ,MAAM,GAAE,YAAiB;IAEtD,4DAA4D;IAC5D,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAgD9B,MAAM,IAAI,YAAY;CASvB;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,QAAQ,EAAE,QAAQ,EAClB,MAAM,GAAE,YAAiB,EACzB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,YAAY,CAAC,CAkCvB"}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * SSE reader for the chat data plane.
3
+ *
4
+ * Every rule here is a fact measured in Phase 0 against real transcripts from
5
+ * DeepSeek, Kimi, Qwen and Vertex Gemini (tests/fixtures/cli-000*), not an
6
+ * assumption about how streaming "usually" works:
7
+ *
8
+ * - `id` and `name` arrive exactly once, so tool calls merge by `index`.
9
+ * - Qwen sends `"id": ""` on continuation frames; `??` would overwrite the
10
+ * real id with an empty string and the tool result would be rejected.
11
+ * - Gemini packs a thought signature into the id (700+ chars) and delivers a
12
+ * whole call in one frame, where Kimi takes nine — fragment counts mean
13
+ * nothing.
14
+ * - `finish_reason` arrives in its own frame with an empty delta; `[DONE]`
15
+ * ends the stream.
16
+ * - With `stream_options.include_usage`, usage arrives AFTER finish_reason
17
+ * in a frame whose `choices` is non-empty but whose delta is empty. A
18
+ * parser that only reads usage when `choices` is empty reports zero cost.
19
+ */
20
+ /** Split a byte stream into SSE `data:` payloads across arbitrary chunking. */
21
+ export class SseFramer {
22
+ buffer = "";
23
+ decoder = new TextDecoder();
24
+ push(chunk) {
25
+ // stream: true keeps a multi-byte character split across chunks intact.
26
+ this.buffer += this.decoder.decode(chunk, { stream: true });
27
+ return this.drain();
28
+ }
29
+ /** Flush whatever a closed stream left behind. */
30
+ end() {
31
+ this.buffer += this.decoder.decode();
32
+ const rest = this.drain();
33
+ const tail = this.buffer.trim();
34
+ this.buffer = "";
35
+ if (tail.startsWith("data:"))
36
+ rest.push(tail.slice("data:".length).trim());
37
+ return rest;
38
+ }
39
+ drain() {
40
+ const payloads = [];
41
+ let newline = this.buffer.indexOf("\n");
42
+ while (newline !== -1) {
43
+ const line = this.buffer.slice(0, newline).trim();
44
+ this.buffer = this.buffer.slice(newline + 1);
45
+ if (line.startsWith("data:"))
46
+ payloads.push(line.slice("data:".length).trim());
47
+ newline = this.buffer.indexOf("\n");
48
+ }
49
+ return payloads;
50
+ }
51
+ }
52
+ /** Accumulates frames into a final answer plus tool calls. */
53
+ export class ChatStreamAssembler {
54
+ events;
55
+ calls = new Map();
56
+ text = "";
57
+ finishReason;
58
+ usage;
59
+ done = false;
60
+ constructor(events = {}) {
61
+ this.events = events;
62
+ }
63
+ /** @returns true when the stream is complete (`[DONE]`). */
64
+ push(payload) {
65
+ if (payload === "[DONE]") {
66
+ this.done = true;
67
+ return true;
68
+ }
69
+ if (payload.length === 0)
70
+ return false;
71
+ let chunk;
72
+ try {
73
+ chunk = JSON.parse(payload);
74
+ }
75
+ catch {
76
+ // A malformed frame must not kill a stream that is otherwise fine.
77
+ return false;
78
+ }
79
+ // Read usage wherever it appears — it rides a frame that still has a
80
+ // (empty-delta) choice on this gateway.
81
+ if (chunk.usage)
82
+ this.usage = chunk.usage;
83
+ const choice = chunk.choices?.[0];
84
+ if (!choice)
85
+ return false;
86
+ if (choice.finish_reason)
87
+ this.finishReason = choice.finish_reason;
88
+ const delta = choice.delta;
89
+ if (!delta)
90
+ return false;
91
+ if (typeof delta.content === "string" && delta.content.length > 0) {
92
+ this.text += delta.content;
93
+ this.events.onText?.(delta.content);
94
+ }
95
+ for (const part of delta.tool_calls ?? []) {
96
+ const index = part.index ?? 0;
97
+ const call = this.calls.get(index) ?? { index, arguments: "" };
98
+ // Non-empty only: Qwen's "" on continuation frames must not win.
99
+ if (part.id)
100
+ call.id = part.id;
101
+ if (part.function?.name)
102
+ call.name = part.function.name;
103
+ if (typeof part.function?.arguments === "string") {
104
+ call.arguments += part.function.arguments;
105
+ }
106
+ this.calls.set(index, call);
107
+ }
108
+ return false;
109
+ }
110
+ result() {
111
+ return {
112
+ text: this.text,
113
+ toolCalls: [...this.calls.values()].sort((a, b) => a.index - b.index),
114
+ finishReason: this.finishReason,
115
+ usage: this.usage,
116
+ done: this.done,
117
+ };
118
+ }
119
+ }
120
+ /**
121
+ * Read a streaming response to completion.
122
+ *
123
+ * `signal` is the user's Ctrl+C: aborting stops reading AND closes the
124
+ * connection, which is what actually tells the gateway to stop generating.
125
+ */
126
+ export async function readChatStream(response, events = {}, signal) {
127
+ const framer = new SseFramer();
128
+ const assembler = new ChatStreamAssembler(events);
129
+ const body = response.body;
130
+ if (!body)
131
+ return assembler.result();
132
+ const reader = body.getReader();
133
+ const onAbort = () => void reader.cancel();
134
+ // Attaching a listener is not enough: a signal that aborted between the
135
+ // request and here would never fire one, and the read below would hang
136
+ // forever on a stream nobody is going to close.
137
+ if (signal?.aborted) {
138
+ await reader.cancel();
139
+ reader.releaseLock();
140
+ return assembler.result();
141
+ }
142
+ signal?.addEventListener("abort", onAbort, { once: true });
143
+ try {
144
+ for (;;) {
145
+ if (signal?.aborted)
146
+ break;
147
+ const { done, value } = await reader.read();
148
+ if (done)
149
+ break;
150
+ for (const payload of framer.push(value)) {
151
+ if (assembler.push(payload))
152
+ return assembler.result();
153
+ }
154
+ }
155
+ for (const payload of framer.end())
156
+ assembler.push(payload);
157
+ }
158
+ finally {
159
+ signal?.removeEventListener("abort", onAbort);
160
+ reader.releaseLock();
161
+ }
162
+ return assembler.result();
163
+ }
@@ -0,0 +1,4 @@
1
+ import { ExitCode } from "../output/exit-codes.js";
2
+ import { type CommandIo } from "./runtime.js";
3
+ export declare function accountCommand(argv: readonly string[], io: CommandIo): Promise<ExitCode>;
4
+ //# sourceMappingURL=account.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account.d.ts","sourceRoot":"","sources":["../../src/commands/account.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAOnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAUtB,wBAAsB,cAAc,CAClC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CAmGnB"}