Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 130 additions & 5 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
isValidModelId,
normalizeModelId,
OPENAI_API_KEY_ENV_KEY,
OPENAI_BASE_URL_ENV_KEY,
OPENROUTER_API_KEY_ENV_KEY,
OPENROUTER_BASE_URL,
OPENROUTER_FALLBACK_MODEL_IDS,
Expand Down Expand Up @@ -71,6 +72,10 @@ export async function runOpenWikiAgent(
emitDebug(options, `model=${modelId}`);

const debugFetchCapture = installOpenRouterDebugFetch(options);
const customBaseURL = process.env[OPENAI_BASE_URL_ENV_KEY];
const restoreFileBlockScrubber = customBaseURL
? installFileBlockScrubber(customBaseURL)
: null;

try {
return await runOpenWikiAgentWithModelFallbacks(
Expand All @@ -86,6 +91,7 @@ export async function runOpenWikiAgent(
throw error;
} finally {
debugFetchCapture.restore();
restoreFileBlockScrubber?.();
}
}

Expand Down Expand Up @@ -377,6 +383,113 @@ function resolveModelId(
return modelId;
}

/**
* Installs a global fetch interceptor that rewrites chat completion request
* bodies destined for a custom base URL, converting any file content blocks
* (base64 binary files sent by DeepAgents) into plain text strings before
* the request leaves the process.
*
* This operates at the HTTP level so it catches all code paths regardless of
* which internal LangChain method DeepAgents uses to invoke the model.
*
* Returns a cleanup function that restores the original fetch.
*/
function installFileBlockScrubber(baseURL: string): () => void {
const originalFetch = globalThis.fetch;

globalThis.fetch = (async (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1],
) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;

if (
typeof url === "string" &&
url.startsWith(baseURL) &&
url.includes("/chat/completions") &&
init?.body &&
typeof init.body === "string"
) {
try {
const parsed = JSON.parse(init.body) as Record<string, unknown>;

if (Array.isArray(parsed.messages)) {
parsed.messages = (
parsed.messages as Array<Record<string, unknown>>
).map(scrubMessageContent);

init = { ...init, body: JSON.stringify(parsed) };
}
} catch {
// Not JSON — pass through unchanged
}
}

return originalFetch(input, init);
}) satisfies typeof fetch;

return () => {
globalThis.fetch = originalFetch;
};
}

function scrubMessageContent(
message: Record<string, unknown>,
): Record<string, unknown> {
const { content } = message;

if (!Array.isArray(content)) {
return message;
}

const scrubbed = (content as Array<unknown>).map((part) => {
if (
typeof part === "object" &&
part !== null &&
"type" in part &&
(part as Record<string, unknown>).type === "file" &&
"data" in part &&
typeof (part as Record<string, unknown>).data === "string"
) {
const text = Buffer.from(
(part as Record<string, unknown>).data as string,
"base64",
).toString("utf8");

return { type: "text", text };
}

return part;
});

// Collapse all-text arrays to a plain string — strict gateways like GLM-5.2
// via Azure APIM reject array content even when every element is text.
const allText = scrubbed.every(
(p) =>
typeof p === "object" &&
p !== null &&
(p as Record<string, unknown>).type === "text",
);

return {
...message,
content: allText
? scrubbed
.map((p) => (p as Record<string, unknown>).text as string)
.join("")
: scrubbed,
};
}

// Keep TextOnlyChatOpenAI as a thin subclass (no overrides needed now that
// the fetch interceptor handles everything at the wire level).
class TextOnlyChatOpenAI extends ChatOpenAI {}

async function createModel(provider: OpenWikiProvider, modelId: string) {
if (provider === "anthropic") {
return new ChatAnthropic(modelId, {
Expand All @@ -398,16 +511,27 @@ async function createModel(provider: OpenWikiProvider, modelId: string) {
}

const providerConfig = getProviderConfig(provider);
const apiKey = process.env[getProviderApiKeyEnvKey(provider)];
const customBaseURL = process.env[OPENAI_BASE_URL_ENV_KEY];
const baseURL = customBaseURL ?? providerConfig.baseURL;

return new ChatOpenAI({
apiKey: process.env[getProviderApiKeyEnvKey(provider)],
configuration: providerConfig.baseURL
const modelParams = {
apiKey,
configuration: baseURL
? {
baseURL: providerConfig.baseURL,
baseURL,
// Azure APIM and similar gateways use api-key instead of Authorization: Bearer
defaultHeaders: customBaseURL ? { "api-key": apiKey } : undefined,
}
: undefined,
model: modelId,
});
};

// Use TextOnlyChatOpenAI for custom base URLs — third-party gateways typically
// don't support multimodal file content blocks that DeepAgents sends for binary files.
return customBaseURL
? new TextOnlyChatOpenAI(modelParams)
: new ChatOpenAI(modelParams);
}

function createModelRoute(
Expand Down Expand Up @@ -1262,6 +1386,7 @@ function formatEnvironmentDebug(): string {
BASETEN_API_KEY_ENV_KEY,
FIREWORKS_API_KEY_ENV_KEY,
OPENAI_API_KEY_ENV_KEY,
OPENAI_BASE_URL_ENV_KEY,
ANTHROPIC_API_KEY_ENV_KEY,
OPENROUTER_API_KEY_ENV_KEY,
OPENWIKI_MODEL_ID_ENV_KEY,
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const UPDATE_METADATA_PATH = `${OPEN_WIKI_DIR}/.last-update.json`;
export const BASETEN_API_KEY_ENV_KEY = "BASETEN_API_KEY";
export const FIREWORKS_API_KEY_ENV_KEY = "FIREWORKS_API_KEY";
export const OPENAI_API_KEY_ENV_KEY = "OPENAI_API_KEY";
export const OPENAI_BASE_URL_ENV_KEY = "OPENAI_BASE_URL";
export const ANTHROPIC_API_KEY_ENV_KEY = "ANTHROPIC_API_KEY";
export const OPENROUTER_API_KEY_ENV_KEY = "OPENROUTER_API_KEY";
export const OPENWIKI_PROVIDER_ENV_KEY = "OPENWIKI_PROVIDER";
Expand Down
4 changes: 3 additions & 1 deletion src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isValidModelId,
normalizeProvider,
OPENAI_API_KEY_ENV_KEY,
OPENAI_BASE_URL_ENV_KEY,
OPENROUTER_API_KEY_ENV_KEY,
OPENWIKI_MODEL_ID_ENV_KEY,
OPENWIKI_PROVIDER_ENV_KEY,
Expand All @@ -34,6 +35,7 @@ const managedEnvKeys = [
BASETEN_API_KEY_ENV_KEY,
FIREWORKS_API_KEY_ENV_KEY,
OPENAI_API_KEY_ENV_KEY,
OPENAI_BASE_URL_ENV_KEY,
ANTHROPIC_API_KEY_ENV_KEY,
OPENROUTER_API_KEY_ENV_KEY,
OPENWIKI_PROVIDER_ENV_KEY,
Expand All @@ -44,7 +46,6 @@ const managedEnvKeys = [
];

const deprecatedEnvKeys = [
"OPENAI_BASE_URL",
"OPENAI_ORG_ID",
"OPENAI_PROJECT",
];
Expand Down Expand Up @@ -75,6 +76,7 @@ export async function getCredentialDiagnostics(): Promise<
createCredentialDiagnostic(BASETEN_API_KEY_ENV_KEY, fileEnv),
createCredentialDiagnostic(FIREWORKS_API_KEY_ENV_KEY, fileEnv),
createCredentialDiagnostic(OPENAI_API_KEY_ENV_KEY, fileEnv),
createCredentialDiagnostic(OPENAI_BASE_URL_ENV_KEY, fileEnv),
createCredentialDiagnostic(ANTHROPIC_API_KEY_ENV_KEY, fileEnv),
createCredentialDiagnostic(OPENROUTER_API_KEY_ENV_KEY, fileEnv),
createCredentialDiagnostic(OPENWIKI_MODEL_ID_ENV_KEY, fileEnv),
Expand Down