diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index 1f718cfd3b3..b55afe8fd58 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -709,6 +709,32 @@ describe("provider attribution", () => { endpointClass: "modelstudio-native", supportsNativeStreamingUsageCompat: true, }); + + expect( + resolveProviderRequestCapabilities({ + provider: "ollama", + api: "openai-completions", + baseUrl: "http://127.0.0.1:11434/v1", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + endpointClass: "local", + supportsNativeStreamingUsageCompat: true, + }); + + expect( + resolveProviderRequestCapabilities({ + provider: "ollama", + api: "openai-completions", + baseUrl: "http://127.0.0.1:11434/v1", + capability: "llm", + transport: "stream", + modelId: "kimi-k2.5:cloud", + }), + ).toMatchObject({ + compatibilityFamily: "moonshot", + }); }); it("treats native GitHub Copilot base URLs as known native endpoints", () => { @@ -902,6 +928,28 @@ describe("provider attribution", () => { supportsNativeStreamingUsageCompat: true, }, }, + { + name: "Ollama OpenAI-compatible completions", + input: { + provider: "ollama", + api: "openai-completions", + baseUrl: "http://127.0.0.1:11434/v1", + capability: "llm" as const, + transport: "stream" as const, + }, + expected: { + knownProviderFamily: "ollama", + endpointClass: "local", + isKnownNativeEndpoint: false, + allowsOpenAIServiceTier: false, + supportsOpenAIReasoningCompatPayload: false, + allowsResponsesStore: false, + supportsResponsesStoreField: false, + shouldStripResponsesPromptCache: false, + allowsAnthropicServiceTier: false, + supportsNativeStreamingUsageCompat: true, + }, + }, { name: "native Google Gemini api", input: { diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index 46fff7b2753..ff0b65321e9 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -132,6 +132,10 @@ const OPENAI_RESPONSES_APIS = new Set([ const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai", "azure-openai-responses"]); const MOONSHOT_COMPAT_PROVIDERS = new Set(["moonshot", "kimi"]); +function isOllamaMoonshotCompatModel(modelId: string | null | undefined): boolean { + return /^kimi-k2\.5(?::|$)/i.test(modelId?.trim() ?? ""); +} + function formatOpenClawUserAgent(version: string): string { return `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${version}`; } @@ -571,7 +575,12 @@ export function resolveProviderRequestCapabilities( endpointClass === "google-vertex"; let compatibilityFamily: ProviderRequestCompatibilityFamily | undefined; - if (provider && MOONSHOT_COMPAT_PROVIDERS.has(provider)) { + const isOllamaOpenAICompletions = provider === "ollama" && api === "openai-completions"; + if ( + provider && + (MOONSHOT_COMPAT_PROVIDERS.has(provider) || + (provider === "ollama" && isOllamaMoonshotCompatModel(input.modelId))) + ) { compatibilityFamily = "moonshot"; } @@ -629,7 +638,9 @@ export function resolveProviderRequestCapabilities( // Native endpoint class is the real signal here. Users can point a generic // provider key at Moonshot or DashScope and still need streaming usage. supportsNativeStreamingUsageCompat: - endpointClass === "moonshot-native" || endpointClass === "modelstudio-native", + endpointClass === "moonshot-native" || + endpointClass === "modelstudio-native" || + isOllamaOpenAICompletions, compatibilityFamily, }; } diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index b654f5ad425..e85e2c8d6ce 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -165,9 +165,7 @@ exit 0 OPENCLAW_PROFILE: "default", HOME: "/Users/testuser", }); - expect(content).toContain( - "exec >>'/Users/testuser/.openclaw/logs/gateway-restart.log' 2>&1 || true", - ); + expect(content).toContain("exec >>'/Users/testuser/.openclaw/logs/gateway-restart.log' 2>&1"); // Every launchctl call should allow output through now (no `2>/dev/null`) // and the final kickstart must not swallow its exit code. expect(content).not.toMatch(/launchctl[^\n]*2>\/dev\/null/); @@ -185,10 +183,10 @@ exit 0 OPENCLAW_STATE_DIR: "/tmp/openclaw-state", }); - expect(content).toContain("mkdir -p '/tmp/openclaw-state/logs' 2>/dev/null || true"); expect(content).toContain( - "exec >>'/tmp/openclaw-state/logs/gateway-restart.log' 2>&1 || true", + "if mkdir -p '/tmp/openclaw-state/logs' 2>/dev/null && : >>'/tmp/openclaw-state/logs/gateway-restart.log' 2>/dev/null; then", ); + expect(content).toContain("exec >>'/tmp/openclaw-state/logs/gateway-restart.log' 2>&1"); await cleanupScript(scriptPath); }); diff --git a/src/daemon/launchd-restart-handoff.test.ts b/src/daemon/launchd-restart-handoff.test.ts index 9293f0f37aa..e0ed59ec4bc 100644 --- a/src/daemon/launchd-restart-handoff.test.ts +++ b/src/daemon/launchd-restart-handoff.test.ts @@ -41,9 +41,7 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => { expect(args[6]).toBe("9876"); expect(args[7]).toBe("ai.openclaw.gateway"); expect(args[1]).toContain('while kill -0 "$wait_pid" >/dev/null 2>&1; do'); - expect(args[1]).toContain( - "exec >>'/Users/test/.openclaw/logs/gateway-restart.log' 2>&1 || true", - ); + expect(args[1]).toContain("exec >>'/Users/test/.openclaw/logs/gateway-restart.log' 2>&1"); expect(args[1]).toContain("openclaw restart attempt source=launchd-handoff mode=kickstart"); expect(args[1]).toContain('launchctl enable "$service_target"'); expect(args[1]).toContain('if launchctl kickstart -k "$service_target"; then'); diff --git a/src/daemon/restart-logs.test.ts b/src/daemon/restart-logs.test.ts index bcfdb84308b..5fa98b31bd5 100644 --- a/src/daemon/restart-logs.test.ts +++ b/src/daemon/restart-logs.test.ts @@ -40,10 +40,10 @@ describe("restart log conventions", () => { HOME: "/Users/test's", }); - expect(setup).toContain("mkdir -p '/Users/test'\\''s/.openclaw/logs' 2>/dev/null || true"); expect(setup).toContain( - "exec >>'/Users/test'\\''s/.openclaw/logs/gateway-restart.log' 2>&1 || true", + "if mkdir -p '/Users/test'\\''s/.openclaw/logs' 2>/dev/null && : >>'/Users/test'\\''s/.openclaw/logs/gateway-restart.log' 2>/dev/null; then", ); + expect(setup).toContain("exec >>'/Users/test'\\''s/.openclaw/logs/gateway-restart.log' 2>&1"); }); it("renders CMD log setup with quoted paths", () => { diff --git a/src/daemon/restart-logs.ts b/src/daemon/restart-logs.ts index 3cac507b18f..fb7241256ff 100644 --- a/src/daemon/restart-logs.ts +++ b/src/daemon/restart-logs.ts @@ -31,8 +31,11 @@ export function shellEscapeRestartLogValue(value: string): string { export function renderPosixRestartLogSetup(env: GatewayServiceEnv): string { const logDir = path.dirname(resolveGatewayRestartLogPath(env)); const logPath = resolveGatewayRestartLogPath(env); - return `mkdir -p '${shellEscapeRestartLogValue(logDir)}' 2>/dev/null || true -exec >>'${shellEscapeRestartLogValue(logPath)}' 2>&1 || true`; + const escapedLogDir = shellEscapeRestartLogValue(logDir); + const escapedLogPath = shellEscapeRestartLogValue(logPath); + return `if mkdir -p '${escapedLogDir}' 2>/dev/null && : >>'${escapedLogPath}' 2>/dev/null; then + exec >>'${escapedLogPath}' 2>&1 +fi`; } export function renderCmdRestartLogSetup(env: GatewayServiceEnv): {