[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
43
infrastructure/ai/sdk/codebuddySdkContract.test.ts
Normal file
43
infrastructure/ai/sdk/codebuddySdkContract.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type {
|
||||
ElicitationCreateRequest,
|
||||
ElicitationHandler,
|
||||
Options,
|
||||
SessionOptions,
|
||||
} from '@tencent-ai/agent-sdk';
|
||||
|
||||
test('CodeBuddy SDK Options and SessionOptions support elicitation handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler: ElicitationHandler = {
|
||||
async create(_request, _options) {
|
||||
calls.push('create');
|
||||
return { action: 'accept', content: { confirmed: true } };
|
||||
},
|
||||
complete() {
|
||||
calls.push('complete');
|
||||
},
|
||||
};
|
||||
const queryOptions = { elicitation: handler } satisfies Options;
|
||||
const sessionOptions = { elicitation: handler } satisfies SessionOptions;
|
||||
const request: ElicitationCreateRequest = {
|
||||
sessionId: 'contract-session',
|
||||
mode: 'form',
|
||||
message: 'Confirm?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { confirmed: { type: 'boolean' } },
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(queryOptions.elicitation, handler);
|
||||
assert.equal(sessionOptions.elicitation, handler);
|
||||
assert.deepEqual(
|
||||
await sessionOptions.elicitation.create(request, {
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
{ action: 'accept', content: { confirmed: true } },
|
||||
);
|
||||
await sessionOptions.elicitation.complete?.({ elicitationId: 'contract-elicit' });
|
||||
assert.deepEqual(calls, ['create', 'complete']);
|
||||
});
|
||||
706
infrastructure/ai/sdk/providers.ts
Normal file
706
infrastructure/ai/sdk/providers.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createGoogle } from '@ai-sdk/google';
|
||||
import type { ProviderConfig, ProviderStyle } from '../types';
|
||||
import { resolveOpenAIApi, resolveProviderStyle } from '../types';
|
||||
import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl';
|
||||
import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl';
|
||||
|
||||
export { normalizeOllamaSdkBaseURL };
|
||||
import {
|
||||
applyOpenAIChatContinuationToBody,
|
||||
extractProviderContinuationFromRawChunk,
|
||||
mergeProviderContinuation,
|
||||
rawOpenAIChatChunkHasToolCalls,
|
||||
repairOpenAIChatToolResultPairsInBody,
|
||||
type OpenAIChatAssistantFields,
|
||||
} from '../providerContinuation';
|
||||
|
||||
export interface ProviderRequestContext {
|
||||
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
|
||||
streamIdleTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge API subset used for SDK fetch adapter.
|
||||
*/
|
||||
interface BridgeAPI {
|
||||
aiFetch(
|
||||
url: string,
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
body?: string,
|
||||
providerId?: string,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: string;
|
||||
error?: string;
|
||||
}>;
|
||||
aiChatStream(
|
||||
requestId: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
providerId?: string,
|
||||
idleTimeoutMs?: number,
|
||||
): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string; aborted?: boolean }>;
|
||||
onAiStreamData(requestId: string, cb: (data: string) => void): () => void;
|
||||
onAiStreamEnd(requestId: string, cb: () => void): () => void;
|
||||
onAiStreamError(requestId: string, cb: (error: string) => void): () => void;
|
||||
aiChatCancel(requestId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
function getBridge(): BridgeAPI | null {
|
||||
const w = window as unknown as { netcatty?: BridgeAPI };
|
||||
return w.netcatty ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a request is likely a streaming request.
|
||||
* AI SDK streaming requests use POST with `"stream": true` in the body.
|
||||
*/
|
||||
function isStreamingRequest(init?: RequestInit): boolean {
|
||||
if (!init?.body) return false;
|
||||
try {
|
||||
const bodyStr = typeof init.body === 'string' ? init.body : null;
|
||||
if (!bodyStr) return false;
|
||||
const parsed = JSON.parse(bodyStr);
|
||||
return parsed.stream === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOpenAIChatAssistantFields(
|
||||
current: OpenAIChatAssistantFields | undefined,
|
||||
incoming: OpenAIChatAssistantFields | undefined,
|
||||
): OpenAIChatAssistantFields | undefined {
|
||||
return mergeProviderContinuation(
|
||||
{ openAIChatAssistantFields: current },
|
||||
{ openAIChatAssistantFields: incoming },
|
||||
)?.openAIChatAssistantFields;
|
||||
}
|
||||
|
||||
function createOpenAIChatStreamFieldCapture(
|
||||
requestContext?: ProviderRequestContext,
|
||||
): (data: string) => void {
|
||||
const assistantFields = requestContext?.getOpenAIChatAssistantFields?.();
|
||||
if (!assistantFields) return () => undefined;
|
||||
|
||||
let streamFieldIndex: number | undefined;
|
||||
let pendingFields: OpenAIChatAssistantFields | undefined;
|
||||
|
||||
const ensureStreamFieldSlot = (): number => {
|
||||
if (streamFieldIndex !== undefined) return streamFieldIndex;
|
||||
streamFieldIndex = assistantFields.length;
|
||||
assistantFields.push(undefined);
|
||||
return streamFieldIndex;
|
||||
};
|
||||
|
||||
const flushPendingFields = (fieldIndex: number) => {
|
||||
if (!pendingFields) return;
|
||||
assistantFields[fieldIndex] = mergeOpenAIChatAssistantFields(
|
||||
assistantFields[fieldIndex],
|
||||
pendingFields,
|
||||
);
|
||||
pendingFields = undefined;
|
||||
};
|
||||
|
||||
return (data: string) => {
|
||||
const continuation = extractProviderContinuationFromRawChunk(data);
|
||||
const fields = continuation?.openAIChatAssistantFields;
|
||||
if (fields) {
|
||||
pendingFields = mergeOpenAIChatAssistantFields(pendingFields, fields);
|
||||
if (streamFieldIndex !== undefined) {
|
||||
flushPendingFields(streamFieldIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawOpenAIChatChunkHasToolCalls(data)) {
|
||||
flushPendingFields(ensureStreamFieldSlot());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) => string {
|
||||
const toolCallIdsByChoiceAndIndex = new Map<string, string>();
|
||||
const toolCallNamesByChoiceAndIndex = new Map<string, string>();
|
||||
const pendingToolCallsByChoiceAndIndex = new Map<string, Record<string, unknown>>();
|
||||
// Full concatenation of every arguments fragment seen for a tool call key,
|
||||
// so a tool call the SDK has not seen yet can be forwarded self-describing.
|
||||
const argumentsSeenByKey = new Map<string, string>();
|
||||
// Keys forwarded inside choices array position 0 — the only element the
|
||||
// AI SDK reads. Tool calls living at position > 0 are invisible to it.
|
||||
const sdkVisibleKeys = new Set<string>();
|
||||
const requestIdToken = requestId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
return (data: string): string => {
|
||||
if (!data || data.trim() === '[DONE]') return data;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as Record<string, unknown>).choices)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const normalizedChoices = ((parsed as Record<string, unknown>).choices as unknown[]).map((choice, choicePosition) => {
|
||||
if (!choice || typeof choice !== 'object') return choice;
|
||||
const choiceRecord = choice as Record<string, unknown>;
|
||||
const delta = choiceRecord.delta;
|
||||
if (!delta || typeof delta !== 'object') return choice;
|
||||
|
||||
const deltaRecord = delta as Record<string, unknown>;
|
||||
if (!Array.isArray(deltaRecord.tool_calls)) return choice;
|
||||
|
||||
const choiceIndex = typeof choiceRecord.index === 'number' ? choiceRecord.index : choicePosition;
|
||||
let deltaChanged = false;
|
||||
const normalizedToolCalls: unknown[] = [];
|
||||
for (const [toolCallPosition, toolCall] of deltaRecord.tool_calls.entries()) {
|
||||
if (!toolCall || typeof toolCall !== 'object') {
|
||||
normalizedToolCalls.push(toolCall);
|
||||
continue;
|
||||
}
|
||||
const toolCallRecord = toolCall as Record<string, unknown>;
|
||||
const toolCallIndex = typeof toolCallRecord.index === 'number' ? toolCallRecord.index : toolCallPosition;
|
||||
const key = `${choiceIndex}:${toolCallIndex}`;
|
||||
const recordFn = toolCallRecord.function;
|
||||
const recordArguments = recordFn && typeof recordFn === 'object'
|
||||
? (recordFn as Record<string, unknown>).arguments
|
||||
: undefined;
|
||||
if (typeof recordArguments === 'string') {
|
||||
argumentsSeenByKey.set(key, (argumentsSeenByKey.get(key) ?? '') + recordArguments);
|
||||
}
|
||||
const existingId = toolCallIdsByChoiceAndIndex.get(key);
|
||||
const pendingToolCall = pendingToolCallsByChoiceAndIndex.get(key);
|
||||
const candidateToolCall = pendingToolCall
|
||||
? mergeOpenAIChatToolCallDeltas(pendingToolCall, toolCallRecord)
|
||||
: toolCallRecord;
|
||||
|
||||
if (existingId) {
|
||||
const rememberedName = toolCallNamesByChoiceAndIndex.get(key);
|
||||
const needsFullArguments = choicePosition === 0 && !sdkVisibleKeys.has(key);
|
||||
let normalizedToolCall = normalizeOpenAIChatToolCall(
|
||||
toolCallRecord,
|
||||
existingId,
|
||||
rememberedName,
|
||||
);
|
||||
if (needsFullArguments) {
|
||||
normalizedToolCall = injectFullOpenAIChatToolCallArguments(
|
||||
normalizedToolCall,
|
||||
argumentsSeenByKey.get(key),
|
||||
);
|
||||
}
|
||||
if (choicePosition === 0) {
|
||||
sdkVisibleKeys.add(key);
|
||||
}
|
||||
if (
|
||||
normalizedToolCall.id === toolCallRecord.id &&
|
||||
normalizedToolCall.type === toolCallRecord.type &&
|
||||
normalizedToolCall.function === toolCallRecord.function &&
|
||||
(!rememberedName || hasFunctionName(toolCallRecord))
|
||||
) {
|
||||
normalizedToolCalls.push(toolCall);
|
||||
} else {
|
||||
changed = true;
|
||||
deltaChanged = true;
|
||||
normalizedToolCalls.push(normalizedToolCall);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasFunctionName(candidateToolCall)) {
|
||||
pendingToolCallsByChoiceAndIndex.set(key, candidateToolCall);
|
||||
changed = true;
|
||||
deltaChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolCallId = typeof candidateToolCall.id === 'string' && candidateToolCall.id
|
||||
? candidateToolCall.id
|
||||
: `call_netcatty_${requestIdToken}_${choiceIndex}_${toolCallIndex}`;
|
||||
toolCallIdsByChoiceAndIndex.set(key, toolCallId);
|
||||
const candidateFunction = candidateToolCall.function;
|
||||
if (candidateFunction && typeof candidateFunction === 'object') {
|
||||
toolCallNamesByChoiceAndIndex.set(
|
||||
key,
|
||||
(candidateFunction as Record<string, unknown>).name as string,
|
||||
);
|
||||
}
|
||||
pendingToolCallsByChoiceAndIndex.delete(key);
|
||||
let normalizedToolCall = normalizeOpenAIChatToolCall(candidateToolCall, toolCallId);
|
||||
if (choicePosition === 0 && !sdkVisibleKeys.has(key)) {
|
||||
normalizedToolCall = injectFullOpenAIChatToolCallArguments(
|
||||
normalizedToolCall,
|
||||
argumentsSeenByKey.get(key),
|
||||
);
|
||||
}
|
||||
if (choicePosition === 0) {
|
||||
sdkVisibleKeys.add(key);
|
||||
}
|
||||
|
||||
if (
|
||||
candidateToolCall === toolCallRecord &&
|
||||
toolCallId === toolCallRecord.id &&
|
||||
normalizedToolCall.type === toolCallRecord.type &&
|
||||
normalizedToolCall.function === toolCallRecord.function
|
||||
) {
|
||||
normalizedToolCalls.push(toolCall);
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
deltaChanged = true;
|
||||
normalizedToolCalls.push(normalizedToolCall);
|
||||
}
|
||||
|
||||
if (!deltaChanged) return choice;
|
||||
return {
|
||||
...choiceRecord,
|
||||
delta: {
|
||||
...deltaRecord,
|
||||
tool_calls: normalizedToolCalls,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) return data;
|
||||
return JSON.stringify({
|
||||
...(parsed as Record<string, unknown>),
|
||||
choices: normalizedChoices,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOpenAIChatToolCallDeltas(
|
||||
current: Record<string, unknown>,
|
||||
incoming: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const currentFn = current.function;
|
||||
const incomingFn = incoming.function;
|
||||
const currentFunction = currentFn && typeof currentFn === 'object'
|
||||
? currentFn as Record<string, unknown>
|
||||
: undefined;
|
||||
const incomingFunction = incomingFn && typeof incomingFn === 'object'
|
||||
? incomingFn as Record<string, unknown>
|
||||
: undefined;
|
||||
const mergedFunction: Record<string, unknown> = {
|
||||
...(currentFunction ?? {}),
|
||||
...(incomingFunction ?? {}),
|
||||
};
|
||||
if (
|
||||
typeof currentFunction?.name === 'string' &&
|
||||
currentFunction.name &&
|
||||
incomingFunction?.name === ''
|
||||
) {
|
||||
mergedFunction.name = currentFunction.name;
|
||||
}
|
||||
const currentArgs = currentFunction?.arguments;
|
||||
const incomingArgs = incomingFunction?.arguments;
|
||||
if (typeof currentArgs === 'string' && typeof incomingArgs === 'string') {
|
||||
mergedFunction.arguments = currentArgs + incomingArgs;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
...incoming,
|
||||
function: mergedFunction,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenAIChatToolCall(
|
||||
toolCall: Record<string, unknown>,
|
||||
toolCallId: string,
|
||||
rememberedName?: string,
|
||||
): Record<string, unknown> {
|
||||
const normalized = { ...toolCall, id: toolCallId };
|
||||
if (
|
||||
normalized.type === '' ||
|
||||
normalized.type == null ||
|
||||
(typeof normalized.type === 'string' && normalized.type !== 'function')
|
||||
) {
|
||||
normalized.type = 'function';
|
||||
}
|
||||
const fn = normalized.function;
|
||||
if (fn && typeof fn === 'object') {
|
||||
const fnRecord = fn as Record<string, unknown>;
|
||||
if (!hasFunctionName({ function: fnRecord })) {
|
||||
const normalizedFunction = { ...fnRecord };
|
||||
if (rememberedName) {
|
||||
normalizedFunction.name = rememberedName;
|
||||
} else if (fnRecord.name === '' || fnRecord.name === null) {
|
||||
delete normalizedFunction.name;
|
||||
}
|
||||
normalized.function = normalizedFunction;
|
||||
}
|
||||
} else if (rememberedName) {
|
||||
normalized.function = { name: rememberedName };
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function injectFullOpenAIChatToolCallArguments(
|
||||
toolCall: Record<string, unknown>,
|
||||
fullArguments: string | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (fullArguments === undefined) return toolCall;
|
||||
const fn = toolCall.function;
|
||||
const fnRecord = fn && typeof fn === 'object' ? fn as Record<string, unknown> : undefined;
|
||||
if (fnRecord?.arguments === fullArguments) return toolCall;
|
||||
return {
|
||||
...toolCall,
|
||||
function: { ...(fnRecord ?? {}), arguments: fullArguments },
|
||||
};
|
||||
}
|
||||
|
||||
function hasFunctionName(toolCall: Record<string, unknown>): boolean {
|
||||
const fn = toolCall.function;
|
||||
return Boolean(
|
||||
fn &&
|
||||
typeof fn === 'object' &&
|
||||
typeof (fn as Record<string, unknown>).name === 'string' &&
|
||||
(fn as Record<string, unknown>).name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers as a plain Record<string, string> from various header formats.
|
||||
*/
|
||||
function extractHeaders(headers?: HeadersInit): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!headers) return result;
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
result[key] = value;
|
||||
}
|
||||
} else {
|
||||
Object.assign(result, headers);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fetch function compatible with the Vercel AI SDK that routes
|
||||
* requests through the Electron IPC bridge to avoid CORS.
|
||||
*
|
||||
* - Non-streaming requests: uses `window.netcatty.aiFetch()` and returns a `Response`.
|
||||
* - Streaming requests: uses `window.netcatty.aiChatStream()` and returns a
|
||||
* `Response` with a `ReadableStream` body.
|
||||
* - Falls back to `globalThis.fetch` if the bridge is unavailable.
|
||||
*/
|
||||
/** Placeholder API key used by the renderer; main process replaces it with the real key. */
|
||||
export const API_KEY_PLACEHOLDER = '__IPC_SECURED__';
|
||||
|
||||
function toSafeStatusText(message: string, fallback: string): string {
|
||||
const normalized = message
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!normalized) return fallback;
|
||||
const byteStringSafe = Array.from(normalized, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code < 0x20 || code === 0x7f || code > 0xff) return '?';
|
||||
return char;
|
||||
}).join('');
|
||||
return byteStringSafe.slice(0, 120) || fallback;
|
||||
}
|
||||
|
||||
export function createBridgeFetchForSDK(
|
||||
providerId?: string,
|
||||
requestContext?: ProviderRequestContext,
|
||||
): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) {
|
||||
return globalThis.fetch(input, init);
|
||||
}
|
||||
|
||||
// Resolve URL string
|
||||
let url: string;
|
||||
let resolvedInit = init;
|
||||
|
||||
if (input instanceof Request) {
|
||||
url = input.url;
|
||||
// Merge Request properties with init overrides
|
||||
if (!resolvedInit) {
|
||||
resolvedInit = {
|
||||
method: input.method,
|
||||
headers: extractHeaders(input.headers),
|
||||
body: input.body ? await new Response(input.body).text() : undefined,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
url = input instanceof URL ? input.toString() : input;
|
||||
}
|
||||
|
||||
const method = resolvedInit?.method || 'GET';
|
||||
const headers = extractHeaders(resolvedInit?.headers);
|
||||
const body =
|
||||
resolvedInit?.body != null ? String(resolvedInit.body) : undefined;
|
||||
const requestBody = body != null
|
||||
? repairOpenAIChatToolResultPairsInBody(applyOpenAIChatContinuationToBody(
|
||||
body,
|
||||
requestContext?.getOpenAIChatAssistantFields?.() ?? [],
|
||||
))
|
||||
: undefined;
|
||||
|
||||
// Streaming path
|
||||
if (isStreamingRequest(resolvedInit)) {
|
||||
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
|
||||
const normalizeOpenAIChatToolCalls = createOpenAIChatToolCallNormalizer(requestId);
|
||||
|
||||
// Set up IPC event listeners BEFORE starting the stream to avoid
|
||||
// missing early events.
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
const pendingChunks: Uint8Array[] = [];
|
||||
let pendingClose = false;
|
||||
let pendingError: Error | null = null;
|
||||
let cleanedUp = false;
|
||||
|
||||
const enqueueChunk = (chunk: Uint8Array) => {
|
||||
if (streamController) {
|
||||
streamController.enqueue(chunk);
|
||||
return;
|
||||
}
|
||||
pendingChunks.push(chunk);
|
||||
};
|
||||
const closeStream = () => {
|
||||
if (streamController) {
|
||||
try { streamController.close(); } catch { /* already closed */ }
|
||||
return;
|
||||
}
|
||||
pendingClose = true;
|
||||
};
|
||||
const errorStream = (error: Error) => {
|
||||
if (streamController) {
|
||||
try { streamController.error(error); } catch { /* already errored */ }
|
||||
return;
|
||||
}
|
||||
pendingError = error;
|
||||
};
|
||||
const flushPendingStreamEvents = (controller: ReadableStreamDefaultController<Uint8Array>) => {
|
||||
for (const chunk of pendingChunks.splice(0)) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
if (pendingError) {
|
||||
controller.error(pendingError);
|
||||
return;
|
||||
}
|
||||
if (pendingClose) {
|
||||
controller.close();
|
||||
}
|
||||
};
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
const normalizedData = normalizeOpenAIChatToolCalls(data);
|
||||
captureOpenAIChatFields(normalizedData);
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
enqueueChunk(encoder.encode(`data: ${normalizedData}\n\n`));
|
||||
});
|
||||
const unsubEnd = bridge.onAiStreamEnd(requestId, () => {
|
||||
closeStream();
|
||||
cleanup();
|
||||
});
|
||||
const unsubError = bridge.onAiStreamError(
|
||||
requestId,
|
||||
(error: string) => {
|
||||
errorStream(new Error(error));
|
||||
cleanup();
|
||||
},
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
unsubData();
|
||||
unsubEnd();
|
||||
unsubError();
|
||||
};
|
||||
|
||||
// Handle abort
|
||||
if (resolvedInit?.signal) {
|
||||
resolvedInit.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
bridge.aiChatCancel(requestId).catch(() => {});
|
||||
errorStream(new DOMException('Aborted', 'AbortError'));
|
||||
cleanup();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Start the stream — resolves once HTTP response headers arrive,
|
||||
// returning the real status code.
|
||||
const result = await bridge.aiChatStream(
|
||||
requestId,
|
||||
url,
|
||||
headers,
|
||||
requestBody || '',
|
||||
providerId,
|
||||
requestContext?.streamIdleTimeoutMs,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
cleanup();
|
||||
// Cancel during proxy lookup / request start must stay an AbortError,
|
||||
// not a synthetic 502 that the AI SDK treats as a provider failure.
|
||||
if (result.aborted || resolvedInit?.signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
const errorMessage = result.error || 'Stream request failed';
|
||||
const jsonBody = JSON.stringify({ error: { message: errorMessage } });
|
||||
return new Response(jsonBody, {
|
||||
status: 502,
|
||||
statusText: toSafeStatusText(errorMessage, 'Bad Gateway'),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// If the server returned a non-2xx status, return the error details
|
||||
// as a JSON body in OpenAI-compatible format so the AI SDK's
|
||||
// failedResponseHandler can extract the message properly.
|
||||
// Also set a safe ASCII statusText as fallback for non-OpenAI SDK providers.
|
||||
const statusCode = result.statusCode ?? 200;
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
cleanup();
|
||||
const errorDetail = result.statusText || `HTTP ${statusCode}`;
|
||||
const jsonBody = JSON.stringify({ error: { message: errorDetail } });
|
||||
return new Response(jsonBody, {
|
||||
status: statusCode,
|
||||
statusText: toSafeStatusText(errorDetail, `Error ${statusCode}`),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
flushPendingStreamEvents(controller);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: statusCode,
|
||||
statusText: result.statusText ?? 'OK',
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
// Non-streaming path
|
||||
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
|
||||
|
||||
return new Response(result.data, {
|
||||
status: result.status,
|
||||
statusText: result.ok ? 'OK' : 'Error',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Vercel AI SDK model instance from a ProviderConfig.
|
||||
*
|
||||
* API keys are NOT sent to the SDK in plaintext. Instead, a placeholder
|
||||
* token is used so the SDK builds proper auth headers, and the main
|
||||
* process replaces the placeholder with the real decrypted key before
|
||||
* making the HTTP request.
|
||||
*/
|
||||
/**
|
||||
* Apply per-vendor URL and apiKey quirks on top of the style-based
|
||||
* wire-protocol routing. Exported so it can be unit-tested without spinning
|
||||
* up the Vercel AI SDK clients.
|
||||
*
|
||||
* The URL fallback fires regardless of style — the user picked this
|
||||
* providerId for a reason, even if they overrode the wire format. The
|
||||
* ollama `'ollama'` throwaway apiKey is only for unauthenticated local
|
||||
* OpenAI-compat servers: Anthropic/Google need a real key, and Ollama
|
||||
* Cloud must keep the IPC placeholder so the main process can inject
|
||||
* the decrypted cloud key.
|
||||
*/
|
||||
export function resolveProviderEndpoint(
|
||||
config: ProviderConfig,
|
||||
style: ProviderStyle,
|
||||
safeApiKey: string | undefined,
|
||||
): { baseURL: string | undefined; apiKey: string | undefined } {
|
||||
let baseURL = config.baseURL;
|
||||
let apiKey = safeApiKey;
|
||||
if (config.providerId === 'ollama') {
|
||||
baseURL = normalizeOllamaSdkBaseURL(baseURL || 'http://localhost:11434/v1');
|
||||
if (style === 'openai' && !apiKey) {
|
||||
apiKey = 'ollama';
|
||||
}
|
||||
} else if (config.providerId === 'openrouter') {
|
||||
baseURL = baseURL || 'https://openrouter.ai/api/v1';
|
||||
}
|
||||
// @ai-sdk/anthropic expects baseURL to include /v1 (then appends /messages).
|
||||
// Bare Claude Code style hosts get /v1 so chat matches probe/discovery.
|
||||
// Custom path prefixes (e.g. …/anthropic) are left alone — they already
|
||||
// complete the SDK base and must not become …/anthropic/v1.
|
||||
if (style === 'anthropic' && baseURL) {
|
||||
baseURL = normalizeAnthropicSdkBaseURL(baseURL);
|
||||
}
|
||||
return { baseURL, apiKey };
|
||||
}
|
||||
|
||||
export function createModelFromConfig(
|
||||
config: ProviderConfig,
|
||||
requestContext?: ProviderRequestContext,
|
||||
) {
|
||||
// Use placeholder API key — the main process will inject the real key
|
||||
const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined;
|
||||
const customFetch = createBridgeFetchForSDK(config.id, requestContext);
|
||||
const modelId = config.defaultModel || '';
|
||||
const style = resolveProviderStyle(config);
|
||||
const { baseURL, apiKey } = resolveProviderEndpoint(config, style, safeApiKey);
|
||||
|
||||
switch (style) {
|
||||
case 'openai': {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: customFetch,
|
||||
});
|
||||
// Chat Completions stays the default so OpenAI-compatible proxies keep
|
||||
// working. Responses is opt-in for relays that cache better on /v1/responses.
|
||||
return resolveOpenAIApi(config) === 'responses'
|
||||
? openai.responses(modelId)
|
||||
: openai.chat(modelId);
|
||||
}
|
||||
|
||||
case 'anthropic':
|
||||
return createAnthropic({
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: customFetch,
|
||||
})(modelId);
|
||||
|
||||
case 'google':
|
||||
return createGoogle({
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: customFetch,
|
||||
})(modelId);
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = style;
|
||||
throw new Error(`Unsupported provider style: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user