mirror of https://github.com/openclaw/openclaw.git
fix(agents): preserve blank local custom-provider API keys after onboarding
Co-authored-by: Xinhua Gu <xinhua.gu@gmail.com>
This commit is contained in:
parent
bed661609e
commit
01674c575e
|
|
@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
|
||||||
- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.
|
- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.
|
||||||
- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks.
|
- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks.
|
||||||
- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.
|
- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.
|
||||||
|
- Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.
|
||||||
|
|
||||||
## 2026.3.12
|
## 2026.3.12
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
|
||||||
export const MINIMAX_OAUTH_MARKER = "minimax-oauth";
|
export const MINIMAX_OAUTH_MARKER = "minimax-oauth";
|
||||||
export const QWEN_OAUTH_MARKER = "qwen-oauth";
|
export const QWEN_OAUTH_MARKER = "qwen-oauth";
|
||||||
export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
|
export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
|
||||||
|
export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local";
|
||||||
export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret
|
export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret
|
||||||
export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret
|
export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret
|
||||||
|
|
||||||
|
|
@ -71,6 +72,7 @@ export function isNonSecretApiKeyMarker(
|
||||||
trimmed === MINIMAX_OAUTH_MARKER ||
|
trimmed === MINIMAX_OAUTH_MARKER ||
|
||||||
trimmed === QWEN_OAUTH_MARKER ||
|
trimmed === QWEN_OAUTH_MARKER ||
|
||||||
trimmed === OLLAMA_LOCAL_AUTH_MARKER ||
|
trimmed === OLLAMA_LOCAL_AUTH_MARKER ||
|
||||||
|
trimmed === CUSTOM_LOCAL_AUTH_MARKER ||
|
||||||
trimmed === NON_ENV_SECRETREF_MARKER ||
|
trimmed === NON_ENV_SECRETREF_MARKER ||
|
||||||
isAwsSdkAuthMarker(trimmed);
|
isAwsSdkAuthMarker(trimmed);
|
||||||
if (isKnownMarker) {
|
if (isKnownMarker) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||||
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
import { CUSTOM_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||||
import {
|
import {
|
||||||
|
applyLocalNoAuthHeaderOverride,
|
||||||
hasUsableCustomProviderApiKey,
|
hasUsableCustomProviderApiKey,
|
||||||
requireApiKey,
|
requireApiKey,
|
||||||
|
resolveApiKeyForProvider,
|
||||||
resolveAwsSdkEnvVarName,
|
resolveAwsSdkEnvVarName,
|
||||||
resolveModelAuthMode,
|
resolveModelAuthMode,
|
||||||
resolveUsableCustomProviderApiKey,
|
resolveUsableCustomProviderApiKey,
|
||||||
|
|
@ -223,3 +226,334 @@ describe("resolveUsableCustomProviderApiKey", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveApiKeyForProvider – synthetic local auth for custom providers", () => {
|
||||||
|
it("synthesizes a local auth marker for custom providers with a local baseUrl and no apiKey", async () => {
|
||||||
|
const auth = await resolveApiKeyForProvider({
|
||||||
|
provider: "custom-127-0-0-1-8080",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"custom-127-0-0-1-8080": {
|
||||||
|
baseUrl: "http://127.0.0.1:8080/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "qwen-3.5",
|
||||||
|
name: "Qwen 3.5",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(auth.apiKey).toBe(CUSTOM_LOCAL_AUTH_MARKER);
|
||||||
|
expect(auth.source).toContain("synthetic local key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synthesizes a local auth marker for localhost custom providers", async () => {
|
||||||
|
const auth = await resolveApiKeyForProvider({
|
||||||
|
provider: "my-local",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"my-local": {
|
||||||
|
baseUrl: "http://localhost:11434/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "llama3",
|
||||||
|
name: "Llama 3",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(auth.apiKey).toBe(CUSTOM_LOCAL_AUTH_MARKER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synthesizes a local auth marker for IPv6 loopback (::1)", async () => {
|
||||||
|
const auth = await resolveApiKeyForProvider({
|
||||||
|
provider: "my-ipv6",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"my-ipv6": {
|
||||||
|
baseUrl: "http://[::1]:8080/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "llama3",
|
||||||
|
name: "Llama 3",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(auth.apiKey).toBe(CUSTOM_LOCAL_AUTH_MARKER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synthesizes a local auth marker for 0.0.0.0", async () => {
|
||||||
|
const auth = await resolveApiKeyForProvider({
|
||||||
|
provider: "my-wildcard",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"my-wildcard": {
|
||||||
|
baseUrl: "http://0.0.0.0:11434/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "qwen",
|
||||||
|
name: "Qwen",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(auth.apiKey).toBe(CUSTOM_LOCAL_AUTH_MARKER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synthesizes a local auth marker for IPv4-mapped IPv6 (::ffff:127.0.0.1)", async () => {
|
||||||
|
const auth = await resolveApiKeyForProvider({
|
||||||
|
provider: "my-mapped",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"my-mapped": {
|
||||||
|
baseUrl: "http://[::ffff:127.0.0.1]:8080/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "llama3",
|
||||||
|
name: "Llama 3",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(auth.apiKey).toBe(CUSTOM_LOCAL_AUTH_MARKER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not synthesize auth for remote custom providers without apiKey", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveApiKeyForProvider({
|
||||||
|
provider: "my-remote",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"my-remote": {
|
||||||
|
baseUrl: "https://api.example.com/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "gpt-5",
|
||||||
|
name: "GPT-5",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("No API key found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not synthesize local auth when apiKey is explicitly configured but unresolved", async () => {
|
||||||
|
const previous = process.env.OPENAI_API_KEY;
|
||||||
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
resolveApiKeyForProvider({
|
||||||
|
provider: "custom",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
baseUrl: "http://127.0.0.1:8080/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: "OPENAI_API_KEY",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "llama3",
|
||||||
|
name: "Llama 3",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('No API key found for provider "custom"');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.OPENAI_API_KEY = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not synthesize local auth when auth mode explicitly requires oauth", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveApiKeyForProvider({
|
||||||
|
provider: "custom",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
baseUrl: "http://127.0.0.1:8080/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
auth: "oauth",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "llama3",
|
||||||
|
name: "Llama 3",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('No API key found for provider "custom"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps built-in aws-sdk fallback for local baseUrl overrides", async () => {
|
||||||
|
const auth = await resolveApiKeyForProvider({
|
||||||
|
provider: "amazon-bedrock",
|
||||||
|
cfg: {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
baseUrl: "http://127.0.0.1:8080/v1",
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.mode).toBe("aws-sdk");
|
||||||
|
expect(auth.apiKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyLocalNoAuthHeaderOverride", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears Authorization for synthetic local OpenAI-compatible auth markers", async () => {
|
||||||
|
let capturedAuthorization: string | null | undefined;
|
||||||
|
let capturedXTest: string | null | undefined;
|
||||||
|
let resolveRequest: (() => void) | undefined;
|
||||||
|
const requestSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
globalThis.fetch = vi.fn(async (_input, init) => {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
capturedAuthorization = headers.get("Authorization");
|
||||||
|
capturedXTest = headers.get("X-Test");
|
||||||
|
resolveRequest?.();
|
||||||
|
return new Response(JSON.stringify({ error: { message: "unauthorized" } }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const model = applyLocalNoAuthHeaderOverride(
|
||||||
|
{
|
||||||
|
id: "local-llm",
|
||||||
|
name: "local-llm",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "custom",
|
||||||
|
baseUrl: "http://127.0.0.1:8080/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 4096,
|
||||||
|
headers: { "X-Test": "1" },
|
||||||
|
} as Model<"openai-completions">,
|
||||||
|
{
|
||||||
|
apiKey: CUSTOM_LOCAL_AUTH_MARKER,
|
||||||
|
source: "models.providers.custom (synthetic local key)",
|
||||||
|
mode: "api-key",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
streamSimpleOpenAICompletions(
|
||||||
|
model,
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "hello",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
apiKey: CUSTOM_LOCAL_AUTH_MARKER,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await requestSeen;
|
||||||
|
|
||||||
|
expect(capturedAuthorization).toBeNull();
|
||||||
|
expect(capturedXTest).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js";
|
import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js";
|
||||||
|
import { coerceSecretRef } from "../config/types.secrets.js";
|
||||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
} from "./auth-profiles.js";
|
} from "./auth-profiles.js";
|
||||||
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
|
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
|
||||||
import {
|
import {
|
||||||
|
CUSTOM_LOCAL_AUTH_MARKER,
|
||||||
isKnownEnvApiKeyMarker,
|
isKnownEnvApiKeyMarker,
|
||||||
isNonSecretApiKeyMarker,
|
isNonSecretApiKeyMarker,
|
||||||
OLLAMA_LOCAL_AUTH_MARKER,
|
OLLAMA_LOCAL_AUTH_MARKER,
|
||||||
|
|
@ -119,15 +121,44 @@ function resolveProviderAuthOverride(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLocalBaseUrl(baseUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
const host = new URL(baseUrl).hostname.toLowerCase();
|
||||||
|
return (
|
||||||
|
host === "localhost" ||
|
||||||
|
host === "127.0.0.1" ||
|
||||||
|
host === "0.0.0.0" ||
|
||||||
|
host === "[::1]" ||
|
||||||
|
host === "[::ffff:7f00:1]" ||
|
||||||
|
host === "[::ffff:127.0.0.1]"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitProviderApiKeyConfig(providerConfig: ModelProviderConfig): boolean {
|
||||||
|
return (
|
||||||
|
normalizeOptionalSecretInput(providerConfig.apiKey) !== undefined ||
|
||||||
|
coerceSecretRef(providerConfig.apiKey) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomLocalProviderConfig(providerConfig: ModelProviderConfig): boolean {
|
||||||
|
return (
|
||||||
|
typeof providerConfig.baseUrl === "string" &&
|
||||||
|
providerConfig.baseUrl.trim().length > 0 &&
|
||||||
|
typeof providerConfig.api === "string" &&
|
||||||
|
providerConfig.api.trim().length > 0 &&
|
||||||
|
Array.isArray(providerConfig.models) &&
|
||||||
|
providerConfig.models.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSyntheticLocalProviderAuth(params: {
|
function resolveSyntheticLocalProviderAuth(params: {
|
||||||
cfg: OpenClawConfig | undefined;
|
cfg: OpenClawConfig | undefined;
|
||||||
provider: string;
|
provider: string;
|
||||||
}): ResolvedProviderAuth | null {
|
}): ResolvedProviderAuth | null {
|
||||||
const normalizedProvider = normalizeProviderId(params.provider);
|
|
||||||
if (normalizedProvider !== "ollama") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerConfig = resolveProviderConfig(params.cfg, params.provider);
|
const providerConfig = resolveProviderConfig(params.cfg, params.provider);
|
||||||
if (!providerConfig) {
|
if (!providerConfig) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -141,11 +172,38 @@ function resolveSyntheticLocalProviderAuth(params: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const normalizedProvider = normalizeProviderId(params.provider);
|
||||||
apiKey: OLLAMA_LOCAL_AUTH_MARKER,
|
if (normalizedProvider === "ollama") {
|
||||||
source: "models.providers.ollama (synthetic local key)",
|
return {
|
||||||
mode: "api-key",
|
apiKey: OLLAMA_LOCAL_AUTH_MARKER,
|
||||||
};
|
source: "models.providers.ollama (synthetic local key)",
|
||||||
|
mode: "api-key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authOverride = resolveProviderAuthOverride(params.cfg, params.provider);
|
||||||
|
if (authOverride && authOverride !== "api-key") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isCustomLocalProviderConfig(providerConfig)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (hasExplicitProviderApiKeyConfig(providerConfig)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom providers pointing at a local server (e.g. llama.cpp, vLLM, LocalAI)
|
||||||
|
// typically don't require auth. Synthesize a local key so the auth resolver
|
||||||
|
// doesn't reject them when the user left the API key blank during onboarding.
|
||||||
|
if (providerConfig.baseUrl && isLocalBaseUrl(providerConfig.baseUrl)) {
|
||||||
|
return {
|
||||||
|
apiKey: CUSTOM_LOCAL_AUTH_MARKER,
|
||||||
|
source: `models.providers.${params.provider} (synthetic local key)`,
|
||||||
|
mode: "api-key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEnvSourceLabel(params: {
|
function resolveEnvSourceLabel(params: {
|
||||||
|
|
@ -439,3 +497,25 @@ export function requireApiKey(auth: ResolvedProviderAuth, provider: string): str
|
||||||
}
|
}
|
||||||
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`);
|
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyLocalNoAuthHeaderOverride<T extends Model<Api>>(
|
||||||
|
model: T,
|
||||||
|
auth: ResolvedProviderAuth | null | undefined,
|
||||||
|
): T {
|
||||||
|
if (auth?.apiKey !== CUSTOM_LOCAL_AUTH_MARKER || model.api !== "openai-completions") {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI's SDK always generates Authorization from apiKey. Keep the non-secret
|
||||||
|
// placeholder so construction succeeds, then clear the header at request build
|
||||||
|
// time for local servers that intentionally do not require auth.
|
||||||
|
const headers = {
|
||||||
|
...model.headers,
|
||||||
|
Authorization: null,
|
||||||
|
} as unknown as Record<string, string>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,11 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||||
import { resolveOpenClawDocsPath } from "../docs-path.js";
|
import { resolveOpenClawDocsPath } from "../docs-path.js";
|
||||||
import { resolveMemorySearchConfig } from "../memory-search.js";
|
import { resolveMemorySearchConfig } from "../memory-search.js";
|
||||||
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
|
import {
|
||||||
|
applyLocalNoAuthHeaderOverride,
|
||||||
|
getApiKeyForModel,
|
||||||
|
resolveModelAuthMode,
|
||||||
|
} from "../model-auth.js";
|
||||||
import { supportsModelTools } from "../model-tool-support.js";
|
import { supportsModelTools } from "../model-tool-support.js";
|
||||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||||
import { createConfiguredOllamaStreamFn } from "../ollama-stream.js";
|
import { createConfiguredOllamaStreamFn } from "../ollama-stream.js";
|
||||||
|
|
@ -429,8 +433,9 @@ export async function compactEmbeddedPiSessionDirect(
|
||||||
const reason = error ?? `Unknown model: ${provider}/${modelId}`;
|
const reason = error ?? `Unknown model: ${provider}/${modelId}`;
|
||||||
return fail(reason);
|
return fail(reason);
|
||||||
}
|
}
|
||||||
|
let apiKeyInfo: Awaited<ReturnType<typeof getApiKeyForModel>> | null = null;
|
||||||
try {
|
try {
|
||||||
const apiKeyInfo = await getApiKeyForModel({
|
apiKeyInfo = await getApiKeyForModel({
|
||||||
model,
|
model,
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
profileId: authProfileId,
|
profileId: authProfileId,
|
||||||
|
|
@ -518,10 +523,12 @@ export async function compactEmbeddedPiSessionDirect(
|
||||||
modelContextWindow: model.contextWindow,
|
modelContextWindow: model.contextWindow,
|
||||||
defaultTokens: DEFAULT_CONTEXT_TOKENS,
|
defaultTokens: DEFAULT_CONTEXT_TOKENS,
|
||||||
});
|
});
|
||||||
const effectiveModel =
|
const effectiveModel = applyLocalNoAuthHeaderOverride(
|
||||||
ctxInfo.tokens < (model.contextWindow ?? Infinity)
|
ctxInfo.tokens < (model.contextWindow ?? Infinity)
|
||||||
? { ...model, contextWindow: ctxInfo.tokens }
|
? { ...model, contextWindow: ctxInfo.tokens }
|
||||||
: model;
|
: model,
|
||||||
|
apiKeyInfo,
|
||||||
|
);
|
||||||
|
|
||||||
const runAbortController = new AbortController();
|
const runAbortController = new AbortController();
|
||||||
const toolsRaw = createOpenClawCodingTools({
|
const toolsRaw = createOpenClawCodingTools({
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||||
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
|
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
|
||||||
import {
|
import {
|
||||||
|
applyLocalNoAuthHeaderOverride,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
getApiKeyForModel,
|
getApiKeyForModel,
|
||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
|
|
@ -884,7 +885,7 @@ export async function runEmbeddedPiAgent(
|
||||||
disableTools: params.disableTools,
|
disableTools: params.disableTools,
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
model: effectiveModel,
|
model: applyLocalNoAuthHeaderOverride(effectiveModel, apiKeyInfo),
|
||||||
authProfileId: lastProfileId,
|
authProfileId: lastProfileId,
|
||||||
authProfileIdSource: lockedProfileId ? "user" : "auto",
|
authProfileIdSource: lockedProfileId ? "user" : "auto",
|
||||||
authStorage,
|
authStorage,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue