pi-devin-auth 0.1.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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Public surface of the cloud-direct module.
3
+ *
4
+ * Usage:
5
+ * import { streamChat } from './cloud-direct/index.js';
6
+ *
7
+ * for await (const delta of streamChat({
8
+ * apiKey: creds.apiKey,
9
+ * apiServerUrl: creds.apiServerUrl,
10
+ * modelUid: 'swe-1-6',
11
+ * messages: [{ role: 'user', content: 'hi' }],
12
+ * })) {
13
+ * process.stdout.write(delta);
14
+ * }
15
+ */
16
+
17
+ export {
18
+ streamChat,
19
+ streamChatEvents,
20
+ allocateCascadeId,
21
+ CloudChatError,
22
+ type CloudChatRequest,
23
+ type ChatHistoryItem,
24
+ type CloudChatEvent,
25
+ type ToolDef,
26
+ } from './chat.js';
27
+
28
+ export {
29
+ mintUserJwt,
30
+ getCachedUserJwt,
31
+ clearCachedUserJwt,
32
+ CloudAuthError,
33
+ } from './auth.js';
34
+
35
+ export {
36
+ getCachedCatalog,
37
+ clearCachedCatalog,
38
+ ModelNotAvailableError,
39
+ type ModelCatalogEntry,
40
+ } from './catalog.js';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `exa.codeium_common_pb.Metadata` proto builder.
3
+ *
4
+ * Field numbers come from src/plugin/discovery.ts (which reads the bundled
5
+ * extension.js for live numbers). For cloud-direct we hard-code the canonical
6
+ * set of fields the LS always populates — the IDE-extracted dynamic numbers
7
+ * would help if Windsurf renumbers, but we don't have a way to refresh those
8
+ * without the bundled extension.js path being present.
9
+ *
10
+ * Captured from real LS upstream traffic via mitm reverse-proxy. See
11
+ * docs/CLOUD_DIRECT.md → "The exact captured request body (annotated)".
12
+ */
13
+
14
+ import {
15
+ encodeMessage,
16
+ encodeString,
17
+ encodeTimestampBody,
18
+ encodeVarintField,
19
+ } from './wire.js';
20
+
21
+ /**
22
+ * extension_version + ide_version sent to the cloud. MUST be a string the
23
+ * cloud recognizes as a real Windsurf release — Cognition's API silently
24
+ * rejects unknown version strings with `failed_precondition: "an internal
25
+ * error occurred"`. We previously tried pulling our package.json version
26
+ * (e.g. "0.3.0") and the cloud rejected every request. Stays pinned to a
27
+ * known-good "2.0.0" until/unless someone explicitly overrides via
28
+ * `MetadataInput.windsurfVersion`.
29
+ */
30
+ const WINDSURF_VERSION_STRING = '2.0.0';
31
+
32
+ export interface MetadataInput {
33
+ /** Persistent api_key from OAuth (`devin-session-token$<JWT>`). */
34
+ apiKey: string;
35
+ /** Fresh user_jwt from GetUserJwt — required for chat methods. */
36
+ userJwt?: string;
37
+ /** UUID — one per opencode session is fine. */
38
+ sessionId: string;
39
+ /** Monotonic, milliseconds since epoch. */
40
+ requestId: bigint;
41
+ /** UUID — one per RPC call. */
42
+ triggerId: string;
43
+ /** Optional override for the version string. Cosmetic. */
44
+ windsurfVersion?: string;
45
+ /** Optional override for the host OS string. */
46
+ osName?: string;
47
+ }
48
+
49
+ function osString(): string {
50
+ switch (process.platform) {
51
+ case 'darwin': return 'darwin';
52
+ case 'linux': return 'linux';
53
+ case 'win32': return 'windows';
54
+ default: return String(process.platform);
55
+ }
56
+ }
57
+
58
+ export function buildMetadata(input: MetadataInput): Buffer {
59
+ const version = input.windsurfVersion ?? WINDSURF_VERSION_STRING;
60
+ const os = input.osName ?? osString();
61
+ const parts: Buffer[] = [
62
+ encodeString(1, 'windsurf'), // ide_name
63
+ encodeString(2, version), // extension_version
64
+ encodeString(3, input.apiKey), // api_key
65
+ encodeString(4, 'en'), // locale
66
+ encodeString(5, os), // os
67
+ encodeString(7, version), // ide_version
68
+ encodeVarintField(9, input.requestId), // request_id (uint64 monotonic)
69
+ encodeString(10, input.sessionId), // session_id
70
+ encodeString(12, 'windsurf'), // extension_name
71
+ encodeMessage(16, encodeTimestampBody()), // ls_timestamp (google.protobuf.Timestamp)
72
+ encodeString(25, input.triggerId), // trigger_id
73
+ encodeString(26, 'Unset'), // plan_name
74
+ encodeString(28, 'windsurf'), // ide_type
75
+ ];
76
+ if (input.userJwt) parts.push(encodeString(21, input.userJwt)); // user_jwt
77
+ return Buffer.concat(parts);
78
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Manual protobuf + Connect-RPC streaming envelope helpers.
3
+ *
4
+ * Connect-RPC streaming wire format (HTTPS POST body):
5
+ * ┌─────────────┬────────────────┬──────────────┐
6
+ * │ flags 1byte │ length 4B BE │ payload │
7
+ * └─────────────┴────────────────┴──────────────┘
8
+ * flags bit 0x01 = payload is gzip-compressed
9
+ * flags bit 0x02 = end-of-stream (trailer frame — JSON {error} or empty {})
10
+ *
11
+ * All `Get*` methods on `exa.api_server_pb.ApiServerService` that the
12
+ * language_server calls upstream use this format, content-type
13
+ * `application/connect+proto`, with `Connect-Protocol-Version: 1`.
14
+ *
15
+ * Kept tiny and dependency-free — same philosophy as src/plugin/protobuf.ts.
16
+ */
17
+
18
+ import * as zlib from 'zlib';
19
+
20
+ // ----------------------------------------------------------------------------
21
+ // Proto wire encode
22
+ // ----------------------------------------------------------------------------
23
+
24
+ export function encodeVarint(value: number | bigint): Buffer {
25
+ const v0 = BigInt(value);
26
+ // Reject negatives at the boundary. Proto3 spec encodes signed types as
27
+ // 10-byte sign-extended varints; we don't support that here and the
28
+ // current call sites never need it (tags, lengths, request ids — all
29
+ // strictly positive). The old loop body would have terminated with
30
+ // `Number(-1n)` = -1, producing a malformed single 0xFF byte that the
31
+ // server would misparse silently. Throw instead so future regressions
32
+ // surface immediately.
33
+ if (v0 < 0n) {
34
+ throw new RangeError(`encodeVarint: negative input not supported (got ${value})`);
35
+ }
36
+ const bytes: number[] = [];
37
+ let v = v0;
38
+ while (v > 127n) {
39
+ bytes.push(Number(v & 0x7fn) | 0x80);
40
+ v >>= 7n;
41
+ }
42
+ bytes.push(Number(v));
43
+ return Buffer.from(bytes);
44
+ }
45
+
46
+ export function encodeTag(fieldNum: number, wire: number): Buffer {
47
+ return encodeVarint((fieldNum << 3) | wire);
48
+ }
49
+
50
+ export function encodeString(fieldNum: number, s: string): Buffer {
51
+ const buf = Buffer.from(s, 'utf8');
52
+ return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(buf.length), buf]);
53
+ }
54
+
55
+ export function encodeMessage(fieldNum: number, body: Buffer): Buffer {
56
+ return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(body.length), body]);
57
+ }
58
+
59
+ export function encodeVarintField(fieldNum: number, v: number | bigint): Buffer {
60
+ return Buffer.concat([encodeTag(fieldNum, 0), encodeVarint(v)]);
61
+ }
62
+
63
+ export function encodeFixed64Field(fieldNum: number, v: number): Buffer {
64
+ const b = Buffer.alloc(8);
65
+ b.writeDoubleLE(v, 0);
66
+ return Buffer.concat([encodeTag(fieldNum, 1), b]);
67
+ }
68
+
69
+ export function encodeTimestampBody(): Buffer {
70
+ const now = Date.now();
71
+ const seconds = Math.floor(now / 1000);
72
+ const nanos = (now % 1000) * 1_000_000;
73
+ return Buffer.concat([
74
+ encodeVarintField(1, seconds),
75
+ nanos > 0 ? encodeVarintField(2, nanos) : Buffer.alloc(0),
76
+ ]);
77
+ }
78
+
79
+ // ----------------------------------------------------------------------------
80
+ // Proto wire decode
81
+ // ----------------------------------------------------------------------------
82
+
83
+ export function decodeVarint(buf: Buffer, offset: number): [bigint, number] {
84
+ let res = 0n;
85
+ let shift = 0n;
86
+ let i = offset;
87
+ while (i < buf.length) {
88
+ const b = buf[i++];
89
+ res |= BigInt(b & 0x7f) << shift;
90
+ if (!(b & 0x80)) return [res, i];
91
+ shift += 7n;
92
+ }
93
+ throw new Error('truncated varint');
94
+ }
95
+
96
+ export interface ProtoField {
97
+ num: number;
98
+ wire: number;
99
+ /** varint → bigint, fixed → 8/4 byte Buffer, length-delim → payload Buffer. */
100
+ value: bigint | Buffer;
101
+ }
102
+
103
+ export function* iterFields(buf: Buffer): Generator<ProtoField> {
104
+ let i = 0;
105
+ while (i < buf.length) {
106
+ const [tagBig, ai] = decodeVarint(buf, i);
107
+ i = ai;
108
+ const tag = Number(tagBig);
109
+ const num = tag >> 3;
110
+ const wire = tag & 0x7;
111
+ if (wire === 0) {
112
+ const [v, bi] = decodeVarint(buf, i);
113
+ i = bi;
114
+ yield { num, wire, value: v };
115
+ } else if (wire === 1) {
116
+ // Bounds-check: a truncated frame mustn't yield a short fixed64 slice
117
+ // that downstream readers treat as a full 8-byte value. Stop iterating
118
+ // cleanly instead.
119
+ if (i + 8 > buf.length) return;
120
+ yield { num, wire, value: buf.slice(i, i + 8) };
121
+ i += 8;
122
+ } else if (wire === 2) {
123
+ const [n, ci] = decodeVarint(buf, i);
124
+ i = ci;
125
+ const len = Number(n);
126
+ // Bounds-check: when the declared length runs past the buffer, the
127
+ // frame is corrupt or truncated. Returning short-buffered slices to
128
+ // downstream parsers used to misparse silently (M12).
129
+ if (len < 0 || i + len > buf.length) return;
130
+ yield { num, wire, value: buf.slice(i, i + len) };
131
+ i += len;
132
+ } else if (wire === 5) {
133
+ if (i + 4 > buf.length) return;
134
+ yield { num, wire, value: buf.slice(i, i + 4) };
135
+ i += 4;
136
+ } else if (wire === 3 || wire === 4) {
137
+ // Wire types 3 (start group) and 4 (end group) are deprecated in
138
+ // proto3 but show up in some Codeium server-generated messages. They
139
+ // carry no length info; the safe behavior is to stop iterating
140
+ // gracefully rather than tear down the whole frame parse.
141
+ return;
142
+ } else {
143
+ // Unknown wire type — bail rather than misalign.
144
+ return;
145
+ }
146
+ }
147
+ }
148
+
149
+ // ----------------------------------------------------------------------------
150
+ // Connect-streaming envelope
151
+ // ----------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Wrap `body` (a serialized proto message) in a Connect-streaming envelope.
155
+ * If `compress` is true, gzip the payload and set the 0x01 flag.
156
+ */
157
+ export function frameConnectStream(body: Buffer, compress = true): Buffer {
158
+ let payload = body;
159
+ let flags = 0;
160
+ if (compress) {
161
+ payload = zlib.gzipSync(body);
162
+ flags |= 0x01;
163
+ }
164
+ const header = Buffer.alloc(5);
165
+ header[0] = flags;
166
+ header.writeUInt32BE(payload.length, 1);
167
+ return Buffer.concat([header, payload]);
168
+ }
169
+
170
+ export interface ConnectFrame {
171
+ flags: number;
172
+ /** Decompressed payload (gzip handled here if flags & 0x01). */
173
+ payload: Buffer;
174
+ /** Frame is the trailer (end-of-stream). */
175
+ eos: boolean;
176
+ }
177
+
178
+ /**
179
+ * Parse all Connect-streaming frames out of a response body.
180
+ *
181
+ * Returns array of decoded frames. Each frame's payload is already gzip-decoded
182
+ * if the compression flag was set.
183
+ */
184
+ export function parseConnectFrames(buf: Buffer): ConnectFrame[] {
185
+ const out: ConnectFrame[] = [];
186
+ let i = 0;
187
+ while (i + 5 <= buf.length) {
188
+ const flags = buf[i];
189
+ const len = buf.readUInt32BE(i + 1);
190
+ if (i + 5 + len > buf.length) break;
191
+ let payload = buf.slice(i + 5, i + 5 + len);
192
+ if (flags & 0x01) {
193
+ // Compressed frame. If gunzip fails the frame is genuinely corrupt
194
+ // — surfacing as a thrown error beats parsing raw gzip bytes as proto
195
+ // (which previously produced misleading "yielded bad wire type" downstream).
196
+ payload = zlib.gunzipSync(payload);
197
+ }
198
+ out.push({ flags, payload, eos: (flags & 0x02) !== 0 });
199
+ i += 5 + len;
200
+ }
201
+ return out;
202
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Pure mapping module that converts pi's conversation model
3
+ * (`Message[]`, `Tool[]`, `systemPrompt`) into the `ChatHistoryItem[]` +
4
+ * `ToolDef[]` shapes that the cloud-direct gRPC layer expects.
5
+ *
6
+ * No side effects, no I/O — trivially unit-testable.
7
+ */
8
+ import type { Context, Message, Tool } from '@earendil-works/pi-ai';
9
+ import type { ChatHistoryItem, ContentPart, ToolDef } from './cloud-direct/chat.js';
10
+
11
+ /** Result of mapping a pi {@link Context} into cloud-direct shapes. */
12
+ export interface MappedChat {
13
+ messages: ChatHistoryItem[];
14
+ tools: ToolDef[];
15
+ }
16
+
17
+ /** pi content-part shapes we care about for mapping. */
18
+ interface TextContent {
19
+ type: 'text';
20
+ text: string;
21
+ }
22
+ interface ImageContent {
23
+ type: 'image';
24
+ data: string;
25
+ mimeType: string;
26
+ }
27
+ interface ThinkingContent {
28
+ type: 'thinking';
29
+ thinking: string;
30
+ }
31
+ interface ToolCallContent {
32
+ type: 'toolCall';
33
+ id: string;
34
+ name: string;
35
+ arguments: Record<string, unknown>;
36
+ }
37
+
38
+ /** Discriminated union of assistant content parts. */
39
+ type AssistantContentPart = TextContent | ThinkingContent | ToolCallContent;
40
+
41
+ /** User/toolResult content parts (no thinking, no toolCall). */
42
+ type UserContentPart = TextContent | ImageContent;
43
+
44
+ /**
45
+ * Map pi user/toolResult content (string or array of text/image parts)
46
+ * into the cloud-direct `ContentPart[]` form. String content is passed
47
+ * through as-is (ChatHistoryItem.content accepts the string shorthand).
48
+ */
49
+ function mapContent(
50
+ content: string | UserContentPart[],
51
+ ): string | ContentPart[] {
52
+ if (typeof content === 'string') return content;
53
+ const out: ContentPart[] = [];
54
+ for (const part of content) {
55
+ if (part.type === 'text') {
56
+ out.push({ type: 'text', text: part.text });
57
+ } else if (part.type === 'image') {
58
+ out.push({
59
+ type: 'image',
60
+ mimeType: part.mimeType,
61
+ base64Data: part.data,
62
+ });
63
+ }
64
+ // Unknown part types are skipped.
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Extract visible text from an assistant message's content array.
71
+ * Joins all `TextContent` entries with `\n`. `ThinkingContent` and
72
+ * `ToolCall` entries are skipped — thinking is internal to the model
73
+ * and tool calls are surfaced separately via {@link extractToolCalls}.
74
+ */
75
+ function extractText(content: AssistantContentPart[]): string {
76
+ const texts: string[] = [];
77
+ for (const part of content) {
78
+ if (part.type === 'text') texts.push(part.text);
79
+ }
80
+ return texts.join('\n');
81
+ }
82
+
83
+ /**
84
+ * Extract tool calls from an assistant message's content array into the
85
+ * cloud-direct `tool_calls` shape. `arguments` is serialized to a JSON
86
+ * string (the cloud proto expects `arguments_json`). Returns `undefined`
87
+ * when there are no tool calls so the `tool_calls` field stays absent on
88
+ * the resulting {@link ChatHistoryItem}.
89
+ */
90
+ function extractToolCalls(
91
+ content: AssistantContentPart[],
92
+ ): Array<{ id: string; name: string; arguments: string }> | undefined {
93
+ const calls: Array<{ id: string; name: string; arguments: string }> = [];
94
+ for (const part of content) {
95
+ if (part.type === 'toolCall') {
96
+ calls.push({
97
+ id: part.id,
98
+ name: part.name,
99
+ arguments: JSON.stringify(part.arguments),
100
+ });
101
+ }
102
+ }
103
+ return calls.length > 0 ? calls : undefined;
104
+ }
105
+
106
+ /**
107
+ * Map a single pi {@link Message} into a cloud-direct {@link ChatHistoryItem}.
108
+ *
109
+ * - `UserMessage` -> `{ role: 'user', content: mapContent(...) }`
110
+ * - `AssistantMessage` -> `{ role: 'assistant', content: extractText(...),
111
+ * tool_calls: extractToolCalls(...) }`
112
+ * - `ToolResultMessage` -> `{ role: 'tool', content: mapContent(...),
113
+ * tool_call_id: msg.toolCallId }`
114
+ */
115
+ function mapMessage(msg: Message): ChatHistoryItem {
116
+ if (msg.role === 'user') {
117
+ return {
118
+ role: 'user',
119
+ content: mapContent(msg.content as string | UserContentPart[]),
120
+ };
121
+ }
122
+ if (msg.role === 'assistant') {
123
+ const parts = msg.content as AssistantContentPart[];
124
+ const toolCalls = extractToolCalls(parts);
125
+ const item: ChatHistoryItem = {
126
+ role: 'assistant',
127
+ content: extractText(parts),
128
+ };
129
+ if (toolCalls !== undefined) item.tool_calls = toolCalls;
130
+ return item;
131
+ }
132
+ // role === 'toolResult'
133
+ return {
134
+ role: 'tool',
135
+ content: mapContent(msg.content as UserContentPart[]),
136
+ tool_call_id: msg.toolCallId,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Convert a pi {@link Context} (systemPrompt + messages + tools) into the
142
+ * `ChatHistoryItem[]` + `ToolDef[]` shapes consumed by the cloud-direct
143
+ * gRPC chat layer.
144
+ *
145
+ * If `context.systemPrompt` is present it is prepended as a `system`
146
+ * message; cloud-direct's `collapseSystemIntoUser` will inline it into
147
+ * the next user turn downstream — we just pass it through here.
148
+ */
149
+ export function mapContextToChat(context: Context): MappedChat {
150
+ const messages: ChatHistoryItem[] = [];
151
+
152
+ if (context.systemPrompt) {
153
+ messages.push({ role: 'system', content: context.systemPrompt });
154
+ }
155
+
156
+ for (const msg of context.messages) {
157
+ messages.push(mapMessage(msg));
158
+ }
159
+
160
+ const tools: ToolDef[] = (context.tools ?? []).map(
161
+ (tool: Tool): ToolDef => ({
162
+ name: tool.name,
163
+ description: tool.description,
164
+ // tool.parameters is a TSchema (JSON Schema object) — pass through.
165
+ parameters: tool.parameters as unknown,
166
+ }),
167
+ );
168
+
169
+ return { messages, tools };
170
+ }
package/src/models.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Resolve the model list for the Devin provider.
3
+ *
4
+ * Fetches the per-account catalog from Cognition's gRPC
5
+ * `GetCascadeModelConfigs` (via {@link getCachedCatalog}) and maps it to
6
+ * pi's {@link ProviderModelConfig} shape. When the fetch fails (offline,
7
+ * auth error, transient outage) or the catalog comes back empty, we hand
8
+ * back {@link FALLBACK_MODELS} so the extension can still show a model
9
+ * picker before login.
10
+ *
11
+ * The catalog only carries `modelUid`, `label`, and `disabled` — it does
12
+ * NOT include context window, max output tokens, or reasoning capability.
13
+ * For known popular models we override those fields from
14
+ * {@link MODEL_OVERRIDES}; unknown UIDs get conservative defaults from
15
+ * the `DEFAULT_*` constants below.
16
+ */
17
+
18
+ import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent';
19
+ import { getCachedCatalog } from './cloud-direct/catalog.js';
20
+
21
+ /** Default Cognition/Codeium host. */
22
+ const DEFAULT_HOST = 'https://server.codeium.com';
23
+
24
+ /**
25
+ * Static fallback list used when the catalog is null or empty (offline,
26
+ * auth error, pre-login). All free for now — pricing TBD.
27
+ */
28
+ export const FALLBACK_MODELS: ProviderModelConfig[] = [
29
+ {
30
+ id: 'swe-1-6',
31
+ name: 'SWE 1.6',
32
+ reasoning: true,
33
+ input: ['text', 'image'],
34
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
35
+ contextWindow: 256000,
36
+ maxTokens: 128000,
37
+ },
38
+ {
39
+ id: 'swe-1-6-slow',
40
+ name: 'SWE 1.6 (Slow)',
41
+ reasoning: true,
42
+ input: ['text', 'image'],
43
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
44
+ contextWindow: 256000,
45
+ maxTokens: 128000,
46
+ },
47
+ {
48
+ id: 'kimi-k2-6',
49
+ name: 'Kimi K2.6',
50
+ reasoning: true,
51
+ input: ['text', 'image'],
52
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
53
+ contextWindow: 256000,
54
+ maxTokens: 128000,
55
+ },
56
+ ];
57
+
58
+ /** Per-model metadata override. All fields optional; missing ones use defaults. */
59
+ interface ModelOverride {
60
+ contextWindow?: number;
61
+ maxTokens?: number;
62
+ reasoning?: boolean;
63
+ input?: ('text' | 'image')[];
64
+ }
65
+
66
+ /**
67
+ * Known-model override table. Cognition's catalog only returns
68
+ * `modelUid`/`label`/`disabled`, so for UIDs we recognise we supply
69
+ * accurate context window, max output tokens, reasoning flag, and input
70
+ * modalities. UIDs not in this map get the `DEFAULT_*` constants.
71
+ */
72
+ const MODEL_OVERRIDES: Map<string, ModelOverride> = new Map([
73
+ // SWE models
74
+ ['swe-1-6', { contextWindow: 256000, maxTokens: 128000, reasoning: true, input: ['text', 'image'] }],
75
+ ['swe-1-6-slow', { contextWindow: 256000, maxTokens: 128000, reasoning: true, input: ['text', 'image'] }],
76
+ // Claude family
77
+ ['claude-opus-4-7-medium', { contextWindow: 200000, maxTokens: 128000, reasoning: true, input: ['text', 'image'] }],
78
+ ['claude-opus-4-7', { contextWindow: 200000, maxTokens: 128000, reasoning: true, input: ['text', 'image'] }],
79
+ ['claude-sonnet-4-6', { contextWindow: 200000, maxTokens: 64000, reasoning: true, input: ['text', 'image'] }],
80
+ ['claude-sonnet-4-5', { contextWindow: 200000, maxTokens: 16384, reasoning: true, input: ['text', 'image'] }],
81
+ // GPT family
82
+ ['gpt-5-5', { contextWindow: 272000, maxTokens: 128000, reasoning: true, input: ['text', 'image'] }],
83
+ ['gpt-5', { contextWindow: 128000, maxTokens: 16384, reasoning: true, input: ['text', 'image'] }],
84
+ // Gemini
85
+ ['gemini-3-0-pro', { contextWindow: 1000000, maxTokens: 65536, reasoning: true, input: ['text', 'image'] }],
86
+ // Kimi
87
+ ['kimi-k2-6', { contextWindow: 256000, maxTokens: 128000, reasoning: true, input: ['text', 'image'] }],
88
+ ]);
89
+
90
+ /** Conservative defaults for catalog UIDs not in the override table. */
91
+ const DEFAULT_CONTEXT_WINDOW = 256000;
92
+ const DEFAULT_MAX_TOKENS = 128000;
93
+ const DEFAULT_REASONING = true;
94
+ const DEFAULT_INPUT: ('text' | 'image')[] = ['text', 'image'];
95
+
96
+ /**
97
+ * Resolve the Devin/Cognition model list for `(apiKey, host)`.
98
+ *
99
+ * Flow:
100
+ * 1. Fetch the per-account catalog via {@link getCachedCatalog}. It
101
+ * already returns `null` on failure; the extra `.catch(() => null)`
102
+ * is defense in depth for unexpected throws.
103
+ * 2. If the catalog is null or empty, return {@link FALLBACK_MODELS} so
104
+ * the extension can still render a picker pre-login / offline.
105
+ * 3. Otherwise map every non-disabled catalog entry to a
106
+ * {@link ProviderModelConfig}, applying per-model overrides for
107
+ * known UIDs and conservative defaults for the rest.
108
+ *
109
+ * All costs are 0 for now (free tier / pricing TBD).
110
+ */
111
+ export async function resolveModels(
112
+ apiKey: string,
113
+ host: string = DEFAULT_HOST,
114
+ ): Promise<ProviderModelConfig[]> {
115
+ const catalog = await getCachedCatalog(apiKey, host).catch(() => null);
116
+
117
+ if (!catalog || catalog.byUid.size === 0) {
118
+ return FALLBACK_MODELS;
119
+ }
120
+
121
+ return [...catalog.byUid.values()]
122
+ .filter((entry) => !entry.disabled)
123
+ .map((entry) => {
124
+ const override = MODEL_OVERRIDES.get(entry.modelUid);
125
+ return {
126
+ id: entry.modelUid,
127
+ name: entry.label || entry.modelUid,
128
+ reasoning: override?.reasoning ?? DEFAULT_REASONING,
129
+ input: override?.input ?? DEFAULT_INPUT,
130
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
131
+ contextWindow: override?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
132
+ maxTokens: override?.maxTokens ?? DEFAULT_MAX_TOKENS,
133
+ };
134
+ });
135
+ }