From d2086bc4856be04aa97771f25d275238a6f70d59 Mon Sep 17 00:00:00 2001 From: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:46:46 +0800 Subject: [PATCH] feat: add Atlas Cloud provider Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- .env.example | 19 +++- README.md | 18 +++- bin/lynkr-init.js | 13 ++- documentation/README.md | 2 +- documentation/contributing.md | 2 +- documentation/features.md | 2 +- documentation/providers.md | 38 ++++++- package.json | 2 +- src/api/providers-handler.js | 13 +++ src/clients/databricks.js | 69 +++++++++++- src/clients/provider-capabilities.js | 2 +- src/config/index.js | 26 ++++- src/orchestrator/index.js | 8 +- src/orchestrator/sse-transformer.js | 1 + src/routing/index.js | 2 + src/routing/model-tiers.js | 2 + test/atlas-integration.test.js | 155 +++++++++++++++++++++++++++ test/dispatch-registry.test.js | 2 +- test/init.test.js | 2 +- 19 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 test/atlas-integration.test.js diff --git a/.env.example b/.env.example index d5ee937..bbc8982 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,9 @@ # 1. TIER ROUTING (the main routing knob — REQUIRED) # ============================================================================== # Format: TIER_=provider:model[:variant] -# Supported providers: ollama, openai, azure-openai, azure-anthropic, openrouter, -# databricks, bedrock, vertex, zai, moonshot, llamacpp, lmstudio +# Supported providers: ollama, openai, atlas, azure-openai, azure-anthropic, +# openrouter, edenai, databricks, bedrock, vertex, zai, +# moonshot, llamacpp, lmstudio # # When all 4 TIER_* are set, Lynkr enters "tier routing mode": # - MODEL_PROVIDER auto-detected from TIER_SIMPLE @@ -45,7 +46,7 @@ TIER_REASONING=moonshot:kimi-k2-thinking # Values: true | false FALLBACK_ENABLED=false # DESCRIPTION: Fallback provider when tier provider fails (cannot be local). -# One of: databricks, azure-anthropic, azure-openai, openrouter, openai, bedrock +# One of: databricks, azure-anthropic, azure-openai, openrouter, edenai, openai, atlas, bedrock FALLBACK_PROVIDER=databricks # DESCRIPTION: [DEPRECATED legacy knob] primary provider for credential validation. # Auto-detected when TIER_* is set. Same allowed values as FALLBACK_PROVIDER plus ollama/llamacpp/lmstudio/vertex/zai/moonshot. @@ -101,6 +102,16 @@ OPENAI_ENDPOINT=https://api.openai.com/v1/chat/completions # DESCRIPTION: Optional OpenAI org id. # OPENAI_ORGANIZATION=org-your-org-id +# ------------------------------------------------------------------------------ +# Atlas Cloud (OpenAI-compatible) +# ------------------------------------------------------------------------------ +# DESCRIPTION: Atlas Cloud API key from https://www.atlascloud.ai/console/api-keys. +# ATLASCLOUD_API_KEY=your-atlas-cloud-api-key +# DESCRIPTION: Default Atlas Cloud text model. +ATLASCLOUD_MODEL=qwen/qwen3.8-max +# DESCRIPTION: Atlas Cloud Chat Completions endpoint. +ATLASCLOUD_ENDPOINT=https://api.atlascloud.ai/v1/chat/completions + # ------------------------------------------------------------------------------ # Ollama (local models) # ------------------------------------------------------------------------------ @@ -441,7 +452,7 @@ LYNKR_STREAM_TRANSFORM=true # Values: true | false (default true — set false to stream) LYNKR_OLLAMA_BUFFER_RESPONSES=true # DESCRIPTION: Providers eligible for the cross-format stream transform. -# Values: comma-separated (default openai,azure-openai,openrouter,databricks,lmstudio,llamacpp) +# Values: comma-separated (default openai,atlas,azure-openai,openrouter,databricks,lmstudio,llamacpp,moonshot) #LYNKR_STREAM_TRANSFORM_PROVIDERS=openai,azure-openai # DESCRIPTION: Abort a passthrough stream when the upstream goes silent this long. LYNKR_STREAM_IDLE_TIMEOUT_MS=60000 diff --git a/README.md b/README.md index be13dab..8c80385 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 84%
JSON Compression 53%
Tool Token Reduction <300ms
Semantic Cache Hits -13+
LLM Providers +14+
LLM Providers 0
Code Changes Required @@ -68,7 +68,7 @@ The fastest path is the interactive wizard: lynkr init ``` -It asks four questions — usage mode (Claude Pro/Max via wrap, or direct API keys), tier picks for SIMPLE/MEDIUM/COMPLEX/REASONING across the 12 supported providers, credentials for what you chose, and a few routing-intelligence knobs — then writes a fully-populated `.env` with sensible production defaults for everything else (caching, compression, policy budgets, MCP sandbox, rate limiting). +It asks four questions — usage mode (Claude Pro/Max via wrap, or direct API keys), tier picks for SIMPLE/MEDIUM/COMPLEX/REASONING across the 14 supported providers, credentials for what you chose, and a few routing-intelligence knobs — then writes a fully-populated `.env` with sensible production defaults for everything else (caching, compression, policy budgets, MCP sandbox, rate limiting). Useful flags: @@ -216,9 +216,21 @@ Claude Code / Cursor / Codex / Cline / Continue | **Azure OpenAI** | Cloud | GPT-4o, o1, o3 | $$$ | | **Azure Anthropic** | Cloud | Claude Sonnet, Opus | $$$ | | **OpenAI** | Cloud | GPT-4o, o3-mini | $$$ | +| **Atlas Cloud** | Cloud | Qwen, DeepSeek, and other OpenAI-compatible models | $-$$$ | | **DeepSeek** | Cloud | DeepSeek R1, Reasoner | $ | -**4 local providers** for 100% offline, free usage. **10+ cloud providers** for scale. +**4 local providers** for 100% offline, free usage. **11+ cloud providers** for scale. + +
+ + + + Atlas Cloud + + +
+ Use Lynkr with Atlas Cloud's OpenAI-compatible API and route across its live model catalog with one optional provider configuration. Explore Atlas Cloud. +
--- diff --git a/bin/lynkr-init.js b/bin/lynkr-init.js index 59ec9bd..ca98a37 100644 --- a/bin/lynkr-init.js +++ b/bin/lynkr-init.js @@ -89,6 +89,17 @@ const PROVIDERS = { extras: [], defaultModel: 'gpt-4o', }, + atlas: { + label: 'Atlas Cloud (OpenAI-compatible)', + local: false, + creds: [ + { key: 'ATLASCLOUD_API_KEY', label: 'Atlas Cloud API key', secret: true }, + ], + extras: [ + { key: 'ATLASCLOUD_ENDPOINT', label: 'Chat Completions endpoint', default: 'https://api.atlascloud.ai/v1/chat/completions' }, + ], + defaultModel: 'qwen/qwen3.8-max', + }, openrouter: { label: 'OpenRouter (100+ models, one key)', local: false, @@ -163,7 +174,7 @@ const PROVIDERS = { const PROVIDER_ORDER = [ 'ollama', 'llamacpp', 'lmstudio', - 'azure-anthropic', 'azure-openai', 'openai', 'openrouter', 'edenai', + 'azure-anthropic', 'azure-openai', 'openai', 'atlas', 'openrouter', 'edenai', 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', ]; const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']; diff --git a/documentation/README.md b/documentation/README.md index fa2aa34..8628545 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -9,7 +9,7 @@ Welcome to the comprehensive documentation for Lynkr, the self-hosted Claude Cod New to Lynkr? Start here: - **[Installation Guide](installation.md)** - Complete installation instructions for all methods (npm, git clone, homebrew, Docker) -- **[Provider Configuration](providers.md)** - Detailed setup for all 12+ supported providers (Databricks, Bedrock, OpenRouter, Ollama, llama.cpp, Azure OpenAI, Azure Anthropic, OpenAI, LM Studio, Moonshot AI, Z.AI, Vertex AI) +- **[Provider Configuration](providers.md)** - Detailed setup for all 14+ supported providers (Databricks, Bedrock, OpenRouter, Eden AI, Ollama, llama.cpp, Azure OpenAI, Azure Anthropic, OpenAI, Atlas Cloud, LM Studio, Moonshot AI, Z.AI, Vertex AI) - **[Quick Start Examples](installation.md#quick-start-examples)** - Copy-paste configurations to get running fast --- diff --git a/documentation/contributing.md b/documentation/contributing.md index fa10a50..7601ee5 100644 --- a/documentation/contributing.md +++ b/documentation/contributing.md @@ -259,7 +259,7 @@ To add support for a new LLM provider: // Add to SUPPORTED_MODEL_PROVIDERS const SUPPORTED_MODEL_PROVIDERS = new Set([ "databricks", "azure-anthropic", "ollama", - "openrouter", "azure-openai", "openai", + "openrouter", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "newprovider" // Add here ]); diff --git a/documentation/features.md b/documentation/features.md index 6c1fb7b..54bbfca 100644 --- a/documentation/features.md +++ b/documentation/features.md @@ -357,7 +357,7 @@ data: {} **End-to-End SSE Through the Tier Router:** - Native passthrough for Anthropic-format upstreams (Anthropic endpoints, Z.AI, Ollama v0.14+ with `LYNKR_OLLAMA_BUFFER_RESPONSES=false`) — upstream bytes piped straight through with backpressure -- OpenAI-format upstreams (openai, azure-openai, openrouter, databricks, llamacpp, lmstudio) transformed to Anthropic SSE in flight, including reassembly of split tool-call argument fragments into complete `tool_use` blocks +- OpenAI-format upstreams (openai, atlas, azure-openai, openrouter, databricks, llamacpp, lmstudio) transformed to Anthropic SSE in flight, including reassembly of split tool-call argument fragments into complete `tool_use` blocks - `LYNKR_VISIBLE_ROUTING` badge injected into live streams as the first content block after `message_start` - If the upstream fails before the first byte, the request falls back to the buffered path; after the first byte, errors surface as SSE error events - Telemetry (latency, tokens, tool calls) recorded by a stream-close finalizer diff --git a/documentation/providers.md b/documentation/providers.md index bd9bbf8..5b038e5 100644 --- a/documentation/providers.md +++ b/documentation/providers.md @@ -1,6 +1,6 @@ # Provider Configuration Guide -Complete configuration reference for all 12+ supported LLM providers. Each provider section includes setup instructions, model options, pricing, and example configurations. +Complete configuration reference for all 14+ supported LLM providers. Each provider section includes setup instructions, model options, pricing, and example configurations. --- @@ -19,6 +19,7 @@ Lynkr supports multiple AI model providers, giving you flexibility in choosing t | **Azure OpenAI** | Cloud | GPT-4o, GPT-5, o1, o3 | $$$ | Cloud | Medium | | **Azure Anthropic** | Cloud | Claude models | $$$ | Cloud | Medium | | **OpenAI** | Cloud | GPT-4o, o1, o3 | $$$ | Cloud | Easy | +| **Atlas Cloud** | Cloud | Qwen, DeepSeek, and other OpenAI-compatible models | $-$$$ | Cloud | Easy | | **Moonshot AI (Kimi)** | Cloud | Kimi K2 (thinking + turbo) | $ | Cloud | Easy | | **LM Studio** | Local | Local models with GUI | **FREE** | 🔒 100% Local | Easy | | **MLX OpenAI Server** | Local | Apple Silicon optimized | **FREE** | 🔒 100% Local | Easy | @@ -725,6 +726,41 @@ OPENAI_MODEL=o1-mini # Smaller reasoning model --- +### 8a. Atlas Cloud (OpenAI-Compatible) + +**Best for:** Accessing Atlas Cloud text models through Lynkr's static or tier-based routing + +#### Configuration + +```env +MODEL_PROVIDER=atlas +ATLASCLOUD_API_KEY=your-atlas-cloud-api-key +ATLASCLOUD_MODEL=qwen/qwen3.8-max +ATLASCLOUD_ENDPOINT=https://api.atlascloud.ai/v1/chat/completions +``` + +Get an API key from the [Atlas Cloud console](https://www.atlascloud.ai/console/api-keys). The endpoint is OpenAI-compatible, so Lynkr converts Anthropic messages and tools to Chat Completions requests and transforms streamed OpenAI SSE events back to Anthropic events. + +Atlas can also be selected per tier: + +```env +TIER_SIMPLE=ollama:qwen2.5-coder:latest +TIER_MEDIUM=atlas:qwen/qwen3.8-max +TIER_COMPLEX=atlas:qwen/qwen3.8-max +TIER_REASONING=atlas:qwen/qwen3.8-max +``` + +`ATLASCLOUD_MODEL` is the static-routing default. A model named in `TIER_*` takes precedence for that tier. Check the [live Atlas Cloud model catalog](https://www.atlascloud.ai/models) before selecting a different model; availability and pricing depend on that model. + +#### Behavior + +- Supports non-streaming and streaming Chat Completions +- Supports Anthropic-format tools through Lynkr's existing OpenAI conversion path +- Uses Bearer authentication and accepts a custom `ATLASCLOUD_ENDPOINT` +- Does not automatically replay billable Chat Completions POST requests + +--- + ### 9. LM Studio (Local with GUI) **Best for:** Local models with graphical interface diff --git a/package.json b/package.json index 7e071b4..e00c7ed 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dev": "nodemon index.js", "lint": "eslint src index.js", "test": "npm run test:unit && npm run test:performance", - "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js", + "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js", "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js", "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js", "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js", diff --git a/src/api/providers-handler.js b/src/api/providers-handler.js index ee80e85..2b86fd6 100644 --- a/src/api/providers-handler.js +++ b/src/api/providers-handler.js @@ -107,6 +107,19 @@ function getConfiguredProviders() { }); } + // Check Atlas Cloud (OpenAI-compatible Chat Completions) + if (config.atlas?.apiKey) { + providers.push({ + name: "atlas", + type: "atlas-cloud", + baseUrl: config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions", + enabled: true, + models: [ + { id: config.atlas.model || "qwen/qwen3.8-max", name: "Configured Model" }, + ] + }); + } + // Check OpenRouter if (config.openrouter?.apiKey) { providers.push({ diff --git a/src/clients/databricks.js b/src/clients/databricks.js index a14493a..a44825b 100644 --- a/src/clients/databricks.js +++ b/src/clients/databricks.js @@ -77,7 +77,7 @@ function _stripInternalFields(body) { return cleaned || body; } -async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride }, providerLabel) { +async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride }, providerLabel) { const agent = url.startsWith('https:') ? httpsAgent : httpAgent; body = _stripInternalFields(body); const isStreaming = body.stream === true; @@ -158,7 +158,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv return result; }, { - maxRetries: config.apiRetry?.maxRetries || 3, + maxRetries: maxRetriesOverride ?? (config.apiRetry?.maxRetries || 3), initialDelay: config.apiRetry?.initialDelay || 1000, maxDelay: config.apiRetry?.maxDelay || 30000, ...(retryableStatusesOverride ? { retryableStatuses: retryableStatusesOverride } : {}), @@ -1385,6 +1385,69 @@ async function invokeOpenAI(body, incomingHeaders = {}) { return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI"); } +async function invokeAtlas(body) { + if (!config.atlas?.apiKey) { + throw new Error("Atlas Cloud API key is not configured."); + } + + const { + convertAnthropicToolsToOpenRouter, + convertAnthropicMessagesToOpenRouter + } = require("./openrouter-utils"); + + const endpoint = config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions"; + const headers = { + "Authorization": `Bearer ${config.atlas.apiKey}`, + "Content-Type": "application/json", + }; + + const messages = convertAnthropicMessagesToOpenRouter(body.messages || []); + if (body.system) { + messages.unshift({ role: "system", content: body.system }); + } + + const atlasBody = { + model: body._suggestionModeModel || body._tierModel || config.atlas.model || "qwen/qwen3.8-max", + messages, + temperature: body.temperature ?? 0.7, + max_tokens: body.max_tokens ?? 16384, + top_p: body.top_p ?? 1.0, + stream: body.stream ?? false, + }; + + let toolsToSend = body.tools; + let toolsInjected = false; + if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) { + toolsToSend = STANDARD_TOOLS; + toolsInjected = true; + } + + if (toolsToSend.length > 0) { + atlasBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend); + atlasBody.parallel_tool_calls = false; + atlasBody.tool_choice = "auto"; + } + + logger.debug({ + endpoint, + model: atlasBody.model, + hasTools: !!atlasBody.tools, + toolCount: atlasBody.tools?.length || 0, + toolsInjected, + temperature: atlasBody.temperature, + max_tokens: atlasBody.max_tokens, + }, "=== ATLAS CLOUD REQUEST ==="); + + // Chat completions are billable POSTs, so Atlas requests are never replayed + // automatically. Callers can retry explicitly with their own idempotency policy. + return performJsonRequest(endpoint, { + headers, + body: atlasBody, + maxRetriesOverride: 0, + retryableStatusesOverride: [], + }, "Atlas Cloud"); +} + async function invokeLlamaCpp(body, incomingHeaders = {}) { if (!config.llamacpp?.endpoint) { throw new Error("llama.cpp endpoint is not configured."); @@ -2751,6 +2814,7 @@ const PROVIDER_INVOKERS = { openrouter: invokeOpenRouter, edenai: invokeEdenAI, openai: invokeOpenAI, + atlas: invokeAtlas, llamacpp: invokeLlamaCpp, lmstudio: invokeLMStudio, bedrock: invokeBedrock, @@ -3540,6 +3604,7 @@ module.exports = { invokeZai, invokeOllama, invokeMoonshot, + invokeAtlas, PROVIDER_INVOKERS, stripLynkrBadges, destroyHttpAgents, diff --git a/src/clients/provider-capabilities.js b/src/clients/provider-capabilities.js index 2badf65..2a56b1c 100644 --- a/src/clients/provider-capabilities.js +++ b/src/clients/provider-capabilities.js @@ -11,7 +11,7 @@ const NATIVE_THINKING_BEDROCK_MODELS = [ "claude-haiku", ]; -const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai"]); +const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "edenai", "openai", "azure-openai", "atlas"]); function supportsNativeThinking(providerType, model) { if (NATIVE_THINKING_PROVIDERS.has(providerType)) return true; diff --git a/src/config/index.js b/src/config/index.js index 586a417..74c88ce 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) { return path.resolve(normalised); } -const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot"]); +const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot"]); const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase(); // Validate MODEL_PROVIDER early with a clear error message @@ -115,6 +115,11 @@ const openAIModel = process.env.OPENAI_MODEL?.trim() || "gpt-4o"; const openAIEndpoint = process.env.OPENAI_ENDPOINT?.trim() || "https://api.openai.com/v1/chat/completions"; const openAIOrganization = process.env.OPENAI_ORGANIZATION?.trim() || null; +// Atlas Cloud configuration (OpenAI-compatible Chat Completions) +const atlasCloudApiKey = process.env.ATLASCLOUD_API_KEY?.trim() || null; +const atlasCloudModel = process.env.ATLASCLOUD_MODEL?.trim() || "qwen/qwen3.8-max"; +const atlasCloudEndpoint = process.env.ATLASCLOUD_ENDPOINT?.trim() || "https://api.atlascloud.ai/v1/chat/completions"; + // llama.cpp configuration const llamacppEndpoint = process.env.LLAMACPP_ENDPOINT?.trim() || "http://localhost:8080"; const llamacppModel = process.env.LLAMACPP_MODEL?.trim() || "default"; @@ -292,6 +297,12 @@ if (modelProvider === "openai" && !openAIApiKey) { ); } +if (modelProvider === "atlas" && !atlasCloudApiKey) { + throw new Error( + "Set ATLASCLOUD_API_KEY before starting the proxy.", + ); +} + if (modelProvider === "ollama") { try { new URL(ollamaEndpoint); @@ -340,7 +351,7 @@ const tiersConfigured = !!( if (fallbackEnabled && tiersConfigured) { const localProviders = ["ollama", "llamacpp", "lmstudio"]; if (localProviders.includes(fallbackProvider)) { - throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, edenai, openai, bedrock`); + throw new Error(`FALLBACK_PROVIDER cannot be '${fallbackProvider}' (local providers should not be fallbacks). Use cloud providers: databricks, azure-anthropic, azure-openai, openrouter, edenai, openai, atlas, bedrock`); } let fallbackMisconfigured = false; if (fallbackProvider === "databricks" && (!rawBaseUrl || !apiKey)) { @@ -355,6 +366,9 @@ if (fallbackEnabled && tiersConfigured) { if (fallbackProvider === "bedrock" && !bedrockApiKey) { fallbackMisconfigured = true; } + if (fallbackProvider === "atlas" && !atlasCloudApiKey) { + fallbackMisconfigured = true; + } if (fallbackMisconfigured) { console.warn(`[WARN] FALLBACK_PROVIDER='${fallbackProvider}' is enabled but missing credentials. Fallback will not work until configured.`); } @@ -602,6 +616,11 @@ var config = { endpoint: openAIEndpoint, organization: openAIOrganization, }, + atlas: { + apiKey: atlasCloudApiKey, + model: atlasCloudModel, + endpoint: atlasCloudEndpoint, + }, llamacpp: { endpoint: llamacppEndpoint, model: llamacppModel, @@ -1085,6 +1104,9 @@ function reloadConfig() { config.edenai.model = process.env.EDENAI_MODEL ?? "openai/gpt-4o-mini"; config.azureOpenAI.apiKey = process.env.AZURE_OPENAI_API_KEY?.trim() || null; config.openai.apiKey = process.env.OPENAI_API_KEY?.trim() || null; + config.atlas.apiKey = process.env.ATLASCLOUD_API_KEY?.trim() || null; + config.atlas.model = process.env.ATLASCLOUD_MODEL?.trim() || "qwen/qwen3.8-max"; + config.atlas.endpoint = process.env.ATLASCLOUD_ENDPOINT?.trim() || "https://api.atlascloud.ai/v1/chat/completions"; config.bedrock.apiKey = process.env.AWS_BEDROCK_API_KEY?.trim() || null; config.zai.apiKey = process.env.ZAI_API_KEY?.trim() || null; config.zai.model = process.env.ZAI_MODEL?.trim() || "GLM-4.7"; diff --git a/src/orchestrator/index.js b/src/orchestrator/index.js index 58bd77c..b0093f4 100644 --- a/src/orchestrator/index.js +++ b/src/orchestrator/index.js @@ -43,7 +43,9 @@ function getDestinationUrl(providerType) { case 'edenai': return config.edenai?.endpoint ?? 'unknown'; case 'openai': - return 'https://api.openai.com/v1/chat/completions'; + return config.openai?.endpoint ?? 'https://api.openai.com/v1/chat/completions'; + case 'atlas': + return config.atlas?.endpoint ?? 'https://api.atlascloud.ai/v1/chat/completions'; case 'llamacpp': return config.llamacpp?.endpoint ?? 'unknown'; case 'lmstudio': @@ -1145,8 +1147,8 @@ function sanitizePayload(payload) { } else { clean.tools = ensureAnthropicToolFormat(clean.tools); } - } else if (providerType === "azure-openai" || providerType === "openai") { - // Azure OpenAI / OpenAI support tools — keep Anthropic format; the + } else if (providerType === "azure-openai" || providerType === "openai" || providerType === "atlas") { + // Azure OpenAI / OpenAI-compatible providers support tools — keep Anthropic format; the // client converts to Chat Completions / Responses format. Without this // branch the unknown-provider catch-all below deletes the tools. if (!Array.isArray(clean.tools) || clean.tools.length === 0) { diff --git a/src/orchestrator/sse-transformer.js b/src/orchestrator/sse-transformer.js index c3f6b6f..edd9630 100644 --- a/src/orchestrator/sse-transformer.js +++ b/src/orchestrator/sse-transformer.js @@ -30,6 +30,7 @@ const logger = require("../logger"); // responses; the buffered path still lifts it into thinking blocks. const DEFAULT_OPENAI_SSE_PROVIDERS = [ "openai", + "atlas", "azure-openai", "openrouter", "databricks", diff --git a/src/routing/index.js b/src/routing/index.js index d451353..7d6d1d6 100644 --- a/src/routing/index.js +++ b/src/routing/index.js @@ -84,6 +84,7 @@ function _enabledProviders() { if (config.openrouter?.apiKey) out.push('openrouter'); if (config.edenai?.apiKey) out.push('edenai'); if (config.openai?.apiKey) out.push('openai'); + if (config.atlas?.apiKey) out.push('atlas'); if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai'); if (config.ollama?.endpoint) out.push('ollama'); if (config.llamacpp?.endpoint) out.push('llamacpp'); @@ -126,6 +127,7 @@ function getBestCloudProvider() { if (config.openrouter?.apiKey) return 'openrouter'; if (config.edenai?.apiKey) return 'edenai'; if (config.openai?.apiKey) return 'openai'; + if (config.atlas?.apiKey) return 'atlas'; if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai'; return getFallbackProvider(); diff --git a/src/routing/model-tiers.js b/src/routing/model-tiers.js index a28ce68..f84de2f 100644 --- a/src/routing/model-tiers.js +++ b/src/routing/model-tiers.js @@ -333,6 +333,8 @@ class ModelTierSelector { return config.azureOpenAI?.deployment || null; case 'openai': return config.openai?.model || null; + case 'atlas': + return config.atlas?.model || null; case 'ollama': return config.ollama?.model || null; case 'openrouter': diff --git a/test/atlas-integration.test.js b/test/atlas-integration.test.js new file mode 100644 index 0000000..ff17067 --- /dev/null +++ b/test/atlas-integration.test.js @@ -0,0 +1,155 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { describe, it, beforeEach, afterEach } = require("node:test"); + +function clearModules() { + for (const modulePath of [ + "../src/config", + "../src/clients/databricks", + "../src/clients/routing", + "../src/routing", + "../src/orchestrator/sse-transformer", + "../src/clients/provider-capabilities", + ]) { + delete require.cache[require.resolve(modulePath)]; + } +} + +describe("Atlas Cloud integration", () => { + let originalEnv; + let originalFetch; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalFetch = global.fetch; + + process.env.MODEL_PROVIDER = "atlas"; + process.env.ATLASCLOUD_API_KEY = "atlas-test-key"; + process.env.ATLASCLOUD_MODEL = ""; + process.env.ATLASCLOUD_ENDPOINT = ""; + process.env.FALLBACK_ENABLED = "false"; + process.env.TIER_SIMPLE = ""; + process.env.TIER_MEDIUM = ""; + process.env.TIER_COMPLEX = ""; + process.env.TIER_REASONING = ""; + process.env.LOG_FILE_ENABLED = "false"; + clearModules(); + }); + + afterEach(() => { + process.env = originalEnv; + global.fetch = originalFetch; + clearModules(); + }); + + it("accepts atlas and applies safe defaults", () => { + const config = require("../src/config"); + + assert.equal(config.modelProvider.type, "atlas"); + assert.equal(config.atlas.model, "qwen/qwen3.8-max"); + assert.equal(config.atlas.endpoint, "https://api.atlascloud.ai/v1/chat/completions"); + }); + + it("requires ATLASCLOUD_API_KEY for primary routing", () => { + process.env.ATLASCLOUD_API_KEY = ""; + clearModules(); + + assert.throws( + () => require("../src/config"), + /Set ATLASCLOUD_API_KEY before starting the proxy/, + ); + }); + + it("honors custom model and endpoint", () => { + process.env.ATLASCLOUD_MODEL = "deepseek-ai/deepseek-v3.2"; + process.env.ATLASCLOUD_ENDPOINT = "https://atlas.example/v1/chat/completions"; + clearModules(); + + const config = require("../src/config"); + assert.equal(config.atlas.model, "deepseek-ai/deepseek-v3.2"); + assert.equal(config.atlas.endpoint, "https://atlas.example/v1/chat/completions"); + }); + + it("converts Anthropic messages and tools to one Atlas request", async () => { + const calls = []; + global.fetch = async (url, options) => { + calls.push({ url, options }); + return new Response(JSON.stringify({ + id: "chatcmpl-atlas-test", + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 }, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const { invokeAtlas } = require("../src/clients/databricks"); + const result = await invokeAtlas({ + system: "Be concise.", + messages: [{ role: "user", content: "Say ok" }], + tools: [{ + name: "get_weather", + description: "Get weather", + input_schema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }], + max_tokens: 32, + stream: false, + }); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.atlascloud.ai/v1/chat/completions"); + assert.equal(calls[0].options.headers.Authorization, "Bearer atlas-test-key"); + + const request = JSON.parse(calls[0].options.body); + assert.equal(request.model, "qwen/qwen3.8-max"); + assert.deepEqual(request.messages[0], { role: "system", content: "Be concise." }); + assert.deepEqual(request.messages[1], { role: "user", content: "Say ok" }); + assert.equal(request.tools[0].type, "function"); + assert.equal(request.tools[0].function.name, "get_weather"); + assert.equal(request.parallel_tool_calls, false); + assert.equal(request.tool_choice, "auto"); + }); + + it("does not replay a failed billable POST", async () => { + let calls = 0; + global.fetch = async () => { + calls += 1; + return new Response(JSON.stringify({ error: { message: "temporary" } }), { + status: 503, + headers: { "content-type": "application/json" }, + }); + }; + + const { invokeAtlas } = require("../src/clients/databricks"); + const result = await invokeAtlas({ + messages: [{ role: "user", content: "hello" }], + tools: [{ name: "noop", description: "No-op", input_schema: { type: "object" } }], + stream: false, + }); + + assert.equal(result.status, 503); + assert.equal(calls, 1); + }); + + it("supports static routing, reasoning content, and OpenAI SSE transforms", async () => { + const routing = require("../src/clients/routing"); + const decision = await routing.determineProviderSmart({ + messages: [{ role: "user", content: "hello" }], + }); + assert.equal(decision.provider, "atlas"); + assert.equal(decision.method, "static"); + + const { supportsReasoningContent } = require("../src/clients/provider-capabilities"); + assert.equal(supportsReasoningContent("atlas"), true); + + const { shouldTransform } = require("../src/orchestrator/sse-transformer"); + assert.equal(shouldTransform(true, "atlas"), true); + }); +}); diff --git a/test/dispatch-registry.test.js b/test/dispatch-registry.test.js index d488128..b179f26 100644 --- a/test/dispatch-registry.test.js +++ b/test/dispatch-registry.test.js @@ -31,7 +31,7 @@ test('PROVIDER_INVOKERS covers every supported provider except databricks', () = // silent databricks dial. const SUPPORTED = [ 'azure-anthropic', 'ollama', 'openrouter', 'edenai', 'azure-openai', - 'openai', 'llamacpp', 'lmstudio', 'bedrock', 'zai', 'vertex', 'moonshot', + 'openai', 'atlas', 'llamacpp', 'lmstudio', 'bedrock', 'zai', 'vertex', 'moonshot', ]; const missing = SUPPORTED.filter((p) => typeof PROVIDER_INVOKERS[p] !== 'function'); diff --git a/test/init.test.js b/test/init.test.js index 77159fe..fce06a1 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -43,7 +43,7 @@ describe("lynkr init", () => { // to the wizard, this test fails loudly. const supported = [ "databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", - "openai", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", + "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", ]; for (const key of supported) { assert.ok(init.PROVIDERS[key], `wizard missing provider entry for ${key}`);