bloby-bot 0.47.0 → 0.47.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/bin/cli.js +42 -8
- package/package.json +3 -2
- package/scripts/postinstall.js +19 -2
- package/scripts/sync-pi-models.ts +146 -0
- package/shared/config.ts +1 -1
- package/supervisor/bloby-agent.ts +2 -0
- package/supervisor/harnesses/pi/async-queue.ts +45 -0
- package/supervisor/harnesses/pi/index.ts +474 -0
- package/supervisor/harnesses/pi/models-catalog.generated.ts +579 -0
- package/supervisor/harnesses/pi/providers/stream-google.ts +156 -0
- package/supervisor/harnesses/pi/providers/stream.ts +21 -0
- package/supervisor/harnesses/pi/providers/types.ts +60 -0
- package/supervisor/harnesses/pi/session.ts +140 -0
- package/supervisor/harnesses/pi/sub-providers.ts +34 -48
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Gemini streaming provider.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written equivalent of the slice of pi-ai/providers/google.ts that bloby
|
|
5
|
+
* needs — text streaming via `:streamGenerateContent?alt=sse`. Function-calling
|
|
6
|
+
* is wired up in Phase 2; for now we drop tools and stream text only.
|
|
7
|
+
*
|
|
8
|
+
* Endpoint: POST {baseUrl}/models/{modelId}:streamGenerateContent?alt=sse&key={apiKey}
|
|
9
|
+
* Stream: SSE — each `data: {...}` is one candidate update.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
PiStreamRequest,
|
|
13
|
+
PiStreamEvent,
|
|
14
|
+
PiMessage,
|
|
15
|
+
PiContentBlock,
|
|
16
|
+
PiStopReason,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
/** Walk an SSE byte stream and yield each parsed JSON event. */
|
|
20
|
+
async function* parseSse(res: Response): AsyncIterable<any> {
|
|
21
|
+
if (!res.body) return;
|
|
22
|
+
const reader = res.body.getReader();
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
let buffer = '';
|
|
25
|
+
try {
|
|
26
|
+
while (true) {
|
|
27
|
+
const { value, done } = await reader.read();
|
|
28
|
+
if (done) break;
|
|
29
|
+
buffer += decoder.decode(value, { stream: true });
|
|
30
|
+
// SSE event boundary is a blank line. Process every complete event in buffer.
|
|
31
|
+
let idx;
|
|
32
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
33
|
+
const raw = buffer.slice(0, idx);
|
|
34
|
+
buffer = buffer.slice(idx + 2);
|
|
35
|
+
const dataLines = raw.split('\n').filter((l) => l.startsWith('data:'));
|
|
36
|
+
if (!dataLines.length) continue;
|
|
37
|
+
const data = dataLines.map((l) => l.slice(5).trimStart()).join('\n');
|
|
38
|
+
if (!data || data === '[DONE]') continue;
|
|
39
|
+
try {
|
|
40
|
+
yield JSON.parse(data);
|
|
41
|
+
} catch {
|
|
42
|
+
// Skip malformed chunks rather than killing the whole turn.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
try { reader.releaseLock(); } catch {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toGeminiRole(role: PiMessage['role']): 'user' | 'model' {
|
|
52
|
+
return role === 'assistant' ? 'model' : 'user';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toGeminiParts(content: PiContentBlock[]): any[] {
|
|
56
|
+
const parts: any[] = [];
|
|
57
|
+
for (const b of content) {
|
|
58
|
+
if (b.type === 'text') parts.push({ text: b.text });
|
|
59
|
+
else if (b.type === 'image') parts.push({ inlineData: { mimeType: b.mediaType, data: b.data } });
|
|
60
|
+
// tool_use / tool_result are Phase 2.
|
|
61
|
+
}
|
|
62
|
+
return parts;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mapStopReason(reason?: string): PiStopReason {
|
|
66
|
+
switch (reason) {
|
|
67
|
+
case 'STOP':
|
|
68
|
+
case 'FINISH_REASON_STOP':
|
|
69
|
+
return 'end_turn';
|
|
70
|
+
case 'MAX_TOKENS':
|
|
71
|
+
return 'max_tokens';
|
|
72
|
+
case 'SAFETY':
|
|
73
|
+
case 'RECITATION':
|
|
74
|
+
case 'OTHER':
|
|
75
|
+
return 'error';
|
|
76
|
+
default:
|
|
77
|
+
return 'end_turn';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStreamEvent> {
|
|
82
|
+
const url =
|
|
83
|
+
`${req.baseUrl.replace(/\/+$/, '')}/models/${encodeURIComponent(req.modelId)}:streamGenerateContent` +
|
|
84
|
+
`?alt=sse&key=${encodeURIComponent(req.apiKey)}`;
|
|
85
|
+
|
|
86
|
+
// Filter out empty messages — Gemini rejects requests with no user content.
|
|
87
|
+
const contents = req.messages
|
|
88
|
+
.filter((m) => m.content.length > 0)
|
|
89
|
+
.map((m) => ({ role: toGeminiRole(m.role), parts: toGeminiParts(m.content) }))
|
|
90
|
+
.filter((m) => m.parts.length > 0);
|
|
91
|
+
|
|
92
|
+
const body: any = {
|
|
93
|
+
contents,
|
|
94
|
+
generationConfig: {
|
|
95
|
+
maxOutputTokens: req.maxOutputTokens ?? 4096,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
if (req.systemPrompt?.trim()) {
|
|
99
|
+
body.systemInstruction = { parts: [{ text: req.systemPrompt }] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let res: Response;
|
|
103
|
+
try {
|
|
104
|
+
res = await fetch(url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'content-type': 'application/json' },
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
signal: req.signal,
|
|
109
|
+
});
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
yield { type: 'error', error: err?.message || String(err) };
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
let detail = '';
|
|
117
|
+
try { detail = await res.text(); } catch {}
|
|
118
|
+
yield { type: 'error', error: `Google ${res.status} ${res.statusText}${detail ? `: ${detail.slice(0, 400)}` : ''}` };
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let accumulated = '';
|
|
123
|
+
let lastFinish: string | undefined;
|
|
124
|
+
let usage: { inputTokens?: number; outputTokens?: number } | undefined;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
for await (const chunk of parseSse(res)) {
|
|
128
|
+
const candidate = chunk?.candidates?.[0];
|
|
129
|
+
const parts: any[] = candidate?.content?.parts || [];
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
if (typeof part?.text === 'string' && part.text.length > 0) {
|
|
132
|
+
accumulated += part.text;
|
|
133
|
+
yield { type: 'text_delta', delta: part.text };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (candidate?.finishReason) lastFinish = candidate.finishReason;
|
|
137
|
+
const usageMeta = chunk?.usageMetadata;
|
|
138
|
+
if (usageMeta) {
|
|
139
|
+
usage = {
|
|
140
|
+
inputTokens: usageMeta.promptTokenCount,
|
|
141
|
+
outputTokens: usageMeta.candidatesTokenCount,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
if (err?.name === 'AbortError') {
|
|
147
|
+
yield { type: 'done', stopReason: 'aborted' };
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
yield { type: 'error', error: err?.message || String(err) };
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (accumulated) yield { type: 'text_end', text: accumulated };
|
|
155
|
+
yield { type: 'done', stopReason: mapStopReason(lastFinish), usage };
|
|
156
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* One function that turns a `(flavor, request)` into a `PiStreamEvent`
|
|
5
|
+
* AsyncIterable. The session loop only knows this entry point — provider
|
|
6
|
+
* choice happens here based on the sub-provider's `flavor` field.
|
|
7
|
+
*/
|
|
8
|
+
import type { PiApiFlavor } from '../sub-providers.js';
|
|
9
|
+
import type { PiStreamRequest, PiStreamEvent } from './types.js';
|
|
10
|
+
import { streamGoogle } from './stream-google.js';
|
|
11
|
+
|
|
12
|
+
export function streamProvider(flavor: PiApiFlavor, req: PiStreamRequest): AsyncIterable<PiStreamEvent> {
|
|
13
|
+
switch (flavor) {
|
|
14
|
+
case 'google-gemini':
|
|
15
|
+
return streamGoogle(req);
|
|
16
|
+
case 'openai-completions':
|
|
17
|
+
throw new Error('openai-completions streaming is not implemented yet (Phase 3).');
|
|
18
|
+
case 'anthropic-messages':
|
|
19
|
+
throw new Error('anthropic-messages streaming is not implemented yet (Phase 3).');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-shared types for the pi harness.
|
|
3
|
+
*
|
|
4
|
+
* One unified message + event shape regardless of which underlying LLM API
|
|
5
|
+
* (Google Gemini, OpenAI-compatible /v1/chat/completions, Anthropic Messages)
|
|
6
|
+
* is handling the request. Each provider implements `streamProvider(req): AsyncIterable<StreamEvent>`
|
|
7
|
+
* and the session loop consumes the events without knowing the flavor.
|
|
8
|
+
*
|
|
9
|
+
* Modelled after pi-ai's event vocabulary (text_start/delta/end, toolcall_*,
|
|
10
|
+
* done, error) so we can copy fixes from upstream if needed, but only the
|
|
11
|
+
* fields bloby actually consumes are kept.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type PiRole = 'user' | 'assistant' | 'tool';
|
|
15
|
+
|
|
16
|
+
/** A single content block inside a message. */
|
|
17
|
+
export type PiContentBlock =
|
|
18
|
+
| { type: 'text'; text: string }
|
|
19
|
+
| { type: 'image'; mediaType: string; data: string } // base64
|
|
20
|
+
| { type: 'tool_use'; id: string; name: string; input: any }
|
|
21
|
+
| { type: 'tool_result'; toolUseId: string; content: string; isError?: boolean };
|
|
22
|
+
|
|
23
|
+
export interface PiMessage {
|
|
24
|
+
role: PiRole;
|
|
25
|
+
content: PiContentBlock[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Schema for one tool the model can call. Plain JSON Schema for input. */
|
|
29
|
+
export interface PiToolDef {
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
inputSchema: Record<string, any>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PiStreamRequest {
|
|
36
|
+
modelId: string;
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
apiKey: string;
|
|
39
|
+
systemPrompt: string;
|
|
40
|
+
messages: PiMessage[];
|
|
41
|
+
tools?: PiToolDef[];
|
|
42
|
+
/** Hard cap on output tokens for a single turn. */
|
|
43
|
+
maxOutputTokens?: number;
|
|
44
|
+
/** Optional abort signal so the session can interrupt in-flight requests. */
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type PiStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'error' | 'aborted';
|
|
49
|
+
|
|
50
|
+
export type PiStreamEvent =
|
|
51
|
+
| { type: 'text_delta'; delta: string }
|
|
52
|
+
| { type: 'text_end'; text: string }
|
|
53
|
+
| { type: 'tool_use'; id: string; name: string; input: any }
|
|
54
|
+
| { type: 'done'; stopReason: PiStopReason; usage?: PiUsage }
|
|
55
|
+
| { type: 'error'; error: string };
|
|
56
|
+
|
|
57
|
+
export interface PiUsage {
|
|
58
|
+
inputTokens?: number;
|
|
59
|
+
outputTokens?: number;
|
|
60
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi agent session — the live conversation loop.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the *shape* of the Claude harness loop in `harnesses/claude.ts`:
|
|
5
|
+
* - one long-lived session per conversation
|
|
6
|
+
* - user messages arrive via an `AsyncQueue<PiMessage>` input
|
|
7
|
+
* - the loop drains the queue one turn at a time
|
|
8
|
+
* - each turn streams provider events back through a single `onEvent`
|
|
9
|
+
* callback the caller hooked up
|
|
10
|
+
*
|
|
11
|
+
* Phase 1 scope: text-only, no tools. Each user turn = one provider call.
|
|
12
|
+
* Phase 2 will plug tools into the inner loop (model emits `tool_use` →
|
|
13
|
+
* execute → append `tool_result` → re-stream → repeat until `end_turn`).
|
|
14
|
+
*
|
|
15
|
+
* Phase 1 explicitly does NOT spawn sub-agents — Bruno will add those later.
|
|
16
|
+
*/
|
|
17
|
+
import { log } from '../../../shared/logger.js';
|
|
18
|
+
import type { PiApiFlavor } from './sub-providers.js';
|
|
19
|
+
import { streamProvider } from './providers/stream.js';
|
|
20
|
+
import type { PiMessage, PiStreamEvent, PiToolDef } from './providers/types.js';
|
|
21
|
+
import type { AsyncQueue } from './async-queue.js';
|
|
22
|
+
|
|
23
|
+
export type PiSessionEvent =
|
|
24
|
+
| { type: 'turn_started' }
|
|
25
|
+
| { type: 'text_delta'; delta: string }
|
|
26
|
+
| { type: 'text_end'; text: string }
|
|
27
|
+
| { type: 'tool_use'; id: string; name: string; input: any } // Phase 2
|
|
28
|
+
| { type: 'turn_complete'; usedFileTools: boolean }
|
|
29
|
+
| { type: 'error'; error: string };
|
|
30
|
+
|
|
31
|
+
export interface PiSessionInit {
|
|
32
|
+
flavor: PiApiFlavor;
|
|
33
|
+
modelId: string;
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
apiKey: string;
|
|
36
|
+
systemPrompt: string;
|
|
37
|
+
/** Pre-loaded history before the first new user turn. */
|
|
38
|
+
initialMessages?: PiMessage[];
|
|
39
|
+
/** Phase 2 wires this through. Empty for Phase 1. */
|
|
40
|
+
tools?: PiToolDef[];
|
|
41
|
+
maxOutputTokens?: number;
|
|
42
|
+
/** Used to interrupt in-flight provider calls when the session ends. */
|
|
43
|
+
abortController: AbortController;
|
|
44
|
+
/** Caller's event sink — translated to bloby's `bot:*` events one layer up. */
|
|
45
|
+
onEvent: (evt: PiSessionEvent) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PiSession {
|
|
49
|
+
/** Resolves when the loop exits (queue closed or aborted). */
|
|
50
|
+
run(input: AsyncQueue<PiMessage>): Promise<void>;
|
|
51
|
+
/** Cumulative history including prefilled context and live turns. */
|
|
52
|
+
getMessages(): PiMessage[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FILE_TOOL_NAMES = new Set(['Write', 'Edit', 'write', 'edit']);
|
|
56
|
+
|
|
57
|
+
export function createPiSession(init: PiSessionInit): PiSession {
|
|
58
|
+
const messages: PiMessage[] = init.initialMessages ? [...init.initialMessages] : [];
|
|
59
|
+
|
|
60
|
+
async function runOneTurn(userMsg: PiMessage): Promise<void> {
|
|
61
|
+
if (init.abortController.signal.aborted) return;
|
|
62
|
+
messages.push(userMsg);
|
|
63
|
+
init.onEvent({ type: 'turn_started' });
|
|
64
|
+
|
|
65
|
+
let accumulated = '';
|
|
66
|
+
const usedTools = new Set<string>();
|
|
67
|
+
let errored = false;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const stream = streamProvider(init.flavor, {
|
|
71
|
+
modelId: init.modelId,
|
|
72
|
+
baseUrl: init.baseUrl,
|
|
73
|
+
apiKey: init.apiKey,
|
|
74
|
+
systemPrompt: init.systemPrompt,
|
|
75
|
+
messages,
|
|
76
|
+
tools: init.tools,
|
|
77
|
+
maxOutputTokens: init.maxOutputTokens,
|
|
78
|
+
signal: init.abortController.signal,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for await (const evt of stream as AsyncIterable<PiStreamEvent>) {
|
|
82
|
+
if (init.abortController.signal.aborted) return;
|
|
83
|
+
switch (evt.type) {
|
|
84
|
+
case 'text_delta':
|
|
85
|
+
accumulated += evt.delta;
|
|
86
|
+
init.onEvent({ type: 'text_delta', delta: evt.delta });
|
|
87
|
+
break;
|
|
88
|
+
case 'text_end':
|
|
89
|
+
// Provider gives us the final accumulated text; trust the deltas
|
|
90
|
+
// we already forwarded and reconcile state from here.
|
|
91
|
+
accumulated = evt.text;
|
|
92
|
+
init.onEvent({ type: 'text_end', text: evt.text });
|
|
93
|
+
break;
|
|
94
|
+
case 'tool_use':
|
|
95
|
+
// Phase 2: execute the tool, append a tool_result message, re-stream.
|
|
96
|
+
usedTools.add(evt.name);
|
|
97
|
+
init.onEvent({ type: 'tool_use', id: evt.id, name: evt.name, input: evt.input });
|
|
98
|
+
break;
|
|
99
|
+
case 'error':
|
|
100
|
+
errored = true;
|
|
101
|
+
init.onEvent({ type: 'error', error: evt.error });
|
|
102
|
+
break;
|
|
103
|
+
case 'done':
|
|
104
|
+
// Loop back if the model is waiting on a tool result (Phase 2);
|
|
105
|
+
// for now `tool_use` is impossible since we don't pass tools.
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
if (init.abortController.signal.aborted) return;
|
|
111
|
+
errored = true;
|
|
112
|
+
init.onEvent({ type: 'error', error: err?.message || String(err) });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (accumulated) {
|
|
116
|
+
messages.push({ role: 'assistant', content: [{ type: 'text', text: accumulated }] });
|
|
117
|
+
}
|
|
118
|
+
if (!errored) {
|
|
119
|
+
const usedFileTools = Array.from(usedTools).some((t) => FILE_TOOL_NAMES.has(t));
|
|
120
|
+
init.onEvent({ type: 'turn_complete', usedFileTools });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
async run(input) {
|
|
126
|
+
for await (const userMsg of input) {
|
|
127
|
+
if (init.abortController.signal.aborted) break;
|
|
128
|
+
try {
|
|
129
|
+
await runOneTurn(userMsg);
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
log.warn(`[pi/session] Turn failed: ${err?.message || err}`);
|
|
132
|
+
init.onEvent({ type: 'error', error: err?.message || String(err) });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
getMessages() {
|
|
137
|
+
return messages;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
* Pro/Max, GitHub Copilot, OpenAI Codex) are deliberately out of scope — they
|
|
11
11
|
* duplicate auth flows we already ship under the dedicated Claude and OpenAI
|
|
12
12
|
* Codex harnesses.
|
|
13
|
+
*
|
|
14
|
+
* Per-provider model lists come from `models-catalog.generated.ts`, which is
|
|
15
|
+
* synced from upstream pi via `npm run sync:pi-models`. Sub-providers without
|
|
16
|
+
* a pi mapping (Ollama, LM Studio, custom) stay `'dynamic'` — free-form ID.
|
|
13
17
|
*/
|
|
18
|
+
import { PI_MODELS_CATALOG } from './models-catalog.generated.js';
|
|
19
|
+
|
|
14
20
|
export type PiApiFlavor = 'openai-completions' | 'anthropic-messages' | 'google-gemini';
|
|
15
21
|
|
|
16
22
|
export interface PiSubProviderModel {
|
|
@@ -37,21 +43,26 @@ export interface PiSubProvider {
|
|
|
37
43
|
defaultModel?: string;
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
function fromCatalog(key: string): PiSubProviderModel[] | 'dynamic' {
|
|
47
|
+
const list = PI_MODELS_CATALOG[key];
|
|
48
|
+
return list && list.length > 0 ? list : 'dynamic';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultFor(key: string): string | undefined {
|
|
52
|
+
return PI_MODELS_CATALOG[key]?.[0]?.id;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
41
56
|
{
|
|
42
57
|
id: 'google',
|
|
43
58
|
name: 'Google Gemini',
|
|
44
|
-
subtitle: '
|
|
59
|
+
subtitle: 'AI Studio API key',
|
|
45
60
|
flavor: 'google-gemini',
|
|
46
61
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
|
47
62
|
needsApiKey: true,
|
|
48
63
|
apiKeyUrl: 'https://aistudio.google.com/apikey',
|
|
49
|
-
models:
|
|
50
|
-
|
|
51
|
-
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
|
52
|
-
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
|
53
|
-
],
|
|
54
|
-
defaultModel: 'gemini-2.5-pro',
|
|
64
|
+
models: fromCatalog('google'),
|
|
65
|
+
defaultModel: defaultFor('google'),
|
|
55
66
|
},
|
|
56
67
|
{
|
|
57
68
|
id: 'deepseek',
|
|
@@ -61,26 +72,19 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
|
61
72
|
baseUrl: 'https://api.deepseek.com/v1',
|
|
62
73
|
needsApiKey: true,
|
|
63
74
|
apiKeyUrl: 'https://platform.deepseek.com/api_keys',
|
|
64
|
-
models:
|
|
65
|
-
|
|
66
|
-
{ id: 'deepseek-reasoner', label: 'DeepSeek R1 (reasoner)' },
|
|
67
|
-
],
|
|
68
|
-
defaultModel: 'deepseek-chat',
|
|
75
|
+
models: fromCatalog('deepseek'),
|
|
76
|
+
defaultModel: defaultFor('deepseek'),
|
|
69
77
|
},
|
|
70
78
|
{
|
|
71
79
|
id: 'groq',
|
|
72
80
|
name: 'Groq',
|
|
73
|
-
subtitle: 'Fast inference
|
|
81
|
+
subtitle: 'Fast inference (Llama / Kimi / Qwen)',
|
|
74
82
|
flavor: 'openai-completions',
|
|
75
83
|
baseUrl: 'https://api.groq.com/openai/v1',
|
|
76
84
|
needsApiKey: true,
|
|
77
85
|
apiKeyUrl: 'https://console.groq.com/keys',
|
|
78
|
-
models:
|
|
79
|
-
|
|
80
|
-
{ id: 'llama-3.1-8b-instant', label: 'Llama 3.1 8B Instant' },
|
|
81
|
-
{ id: 'moonshotai/kimi-k2-instruct', label: 'Kimi K2 Instruct' },
|
|
82
|
-
],
|
|
83
|
-
defaultModel: 'llama-3.3-70b-versatile',
|
|
86
|
+
models: fromCatalog('groq'),
|
|
87
|
+
defaultModel: defaultFor('groq'),
|
|
84
88
|
},
|
|
85
89
|
{
|
|
86
90
|
id: 'xai',
|
|
@@ -90,12 +94,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
|
90
94
|
baseUrl: 'https://api.x.ai/v1',
|
|
91
95
|
needsApiKey: true,
|
|
92
96
|
apiKeyUrl: 'https://console.x.ai/',
|
|
93
|
-
models:
|
|
94
|
-
|
|
95
|
-
{ id: 'grok-code-fast-1', label: 'Grok Code Fast 1' },
|
|
96
|
-
{ id: 'grok-3', label: 'Grok 3' },
|
|
97
|
-
],
|
|
98
|
-
defaultModel: 'grok-4',
|
|
97
|
+
models: fromCatalog('xai'),
|
|
98
|
+
defaultModel: defaultFor('xai'),
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
id: 'cerebras',
|
|
@@ -105,11 +105,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
|
105
105
|
baseUrl: 'https://api.cerebras.ai/v1',
|
|
106
106
|
needsApiKey: true,
|
|
107
107
|
apiKeyUrl: 'https://cloud.cerebras.ai/?tab=api-keys',
|
|
108
|
-
models:
|
|
109
|
-
|
|
110
|
-
{ id: 'llama-3.3-70b', label: 'Llama 3.3 70B' },
|
|
111
|
-
],
|
|
112
|
-
defaultModel: 'qwen-3-coder-480b',
|
|
108
|
+
models: fromCatalog('cerebras'),
|
|
109
|
+
defaultModel: defaultFor('cerebras'),
|
|
113
110
|
},
|
|
114
111
|
{
|
|
115
112
|
id: 'openrouter',
|
|
@@ -119,6 +116,7 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
|
119
116
|
baseUrl: 'https://openrouter.ai/api/v1',
|
|
120
117
|
needsApiKey: true,
|
|
121
118
|
apiKeyUrl: 'https://openrouter.ai/keys',
|
|
119
|
+
// OpenRouter has 270+ entries — too many to list. Free-form ID input instead.
|
|
122
120
|
models: 'dynamic',
|
|
123
121
|
defaultModel: 'anthropic/claude-sonnet-4',
|
|
124
122
|
},
|
|
@@ -130,11 +128,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
|
130
128
|
baseUrl: 'https://api.mistral.ai/v1',
|
|
131
129
|
needsApiKey: true,
|
|
132
130
|
apiKeyUrl: 'https://console.mistral.ai/api-keys/',
|
|
133
|
-
models:
|
|
134
|
-
|
|
135
|
-
{ id: 'codestral-latest', label: 'Codestral' },
|
|
136
|
-
],
|
|
137
|
-
defaultModel: 'mistral-large-latest',
|
|
131
|
+
models: fromCatalog('mistral'),
|
|
132
|
+
defaultModel: defaultFor('mistral'),
|
|
138
133
|
},
|
|
139
134
|
{
|
|
140
135
|
id: 'openai-api',
|
|
@@ -144,13 +139,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
|
144
139
|
baseUrl: 'https://api.openai.com/v1',
|
|
145
140
|
needsApiKey: true,
|
|
146
141
|
apiKeyUrl: 'https://platform.openai.com/api-keys',
|
|
147
|
-
models:
|
|
148
|
-
|
|
149
|
-
{ id: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
|
150
|
-
{ id: 'gpt-4.1', label: 'GPT-4.1' },
|
|
151
|
-
{ id: 'o3', label: 'o3' },
|
|
152
|
-
],
|
|
153
|
-
defaultModel: 'gpt-5',
|
|
142
|
+
models: fromCatalog('openai-api'),
|
|
143
|
+
defaultModel: defaultFor('openai-api'),
|
|
154
144
|
},
|
|
155
145
|
{
|
|
156
146
|
id: 'anthropic-api',
|
|
@@ -160,12 +150,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
|
|
|
160
150
|
baseUrl: 'https://api.anthropic.com/v1',
|
|
161
151
|
needsApiKey: true,
|
|
162
152
|
apiKeyUrl: 'https://console.anthropic.com/settings/keys',
|
|
163
|
-
models:
|
|
164
|
-
|
|
165
|
-
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
|
166
|
-
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
|
|
167
|
-
],
|
|
168
|
-
defaultModel: 'claude-sonnet-4-5',
|
|
153
|
+
models: fromCatalog('anthropic-api'),
|
|
154
|
+
defaultModel: defaultFor('anthropic-api'),
|
|
169
155
|
},
|
|
170
156
|
{
|
|
171
157
|
id: 'ollama',
|