Skip to content

feat(ai): add OpenAI-compatible provider (custom base URL)#97

Open
Mariomarquezt wants to merge 7 commits into
CoreBunch:mainfrom
Mariomarquezt:feat/openai-compatible-provider
Open

feat(ai): add OpenAI-compatible provider (custom base URL)#97
Mariomarquezt wants to merge 7 commits into
CoreBunch:mainfrom
Mariomarquezt:feat/openai-compatible-provider

Conversation

@Mariomarquezt

Copy link
Copy Markdown

What & why

Instatic's AI provider list is closed (Anthropic / OpenAI / OpenRouter / Ollama). This adds a generic OpenAI-Compatible provider so the editor agent can target any endpoint that speaks the OpenAI /v1/chat/completions wire protocol — Groq, Together, DeepSeek, Fireworks, MiniMax, self-hosted vLLM / LM Studio, and aggregator gateways — via a custom base URL + optional API key.

Approach

  • Refactor (no duplication): extracts the chat/completions machinery out of ollama.ts into a shared server/ai/drivers/http/chatCompletions.ts; both ollama and the new driver consume it.
  • New driver server/ai/drivers/openaiCompatible.ts: baseUrl auth (optional bearer), model discovery via GET {baseUrl}/v1/models, generic capability defaults.
  • Registered across the backend (AiProviderId, DRIVERS, credentials + models handlers) and the admin UI (ProvidersTab, api.ts).
  • Robustness for real gateways: base-URL normalization tolerates an optional trailing /v1 (so both …/openai and …/openai/v1 work); the SSE chunk schema tolerates explicit null for usage / tool_calls / content, which many gateways send on every chunk — without this, parseValue throws and the model's entire reply is silently dropped.
  • No DB migration (the provider_id enum constraint was already dropped; validation is at the app boundary).

User / developer impact

Operators can add any OpenAI-compatible endpoint under Settings → AI → Providers → "OpenAI-Compatible", enter a base URL + key, and use it like any built-in provider. The shared adapter also hardens Ollama against the same null-chunk variance.

Verification

  • bun test — new driver + shared-adapter unit tests (model-list parse, failure→[], SSE translation, null-tolerance), plus updated architecture gate.
  • bun run build (tsc -b && vite build) and bun run lint — green.
  • Live-tested end-to-end against the OpenCode Zen gateway (built a full multi-section site through the agent).

Disclosure

Authored with Claude Code (Claude Opus 4.8), reviewed and live-tested by the submitter. Harness: Claude Code.

Mario and others added 7 commits June 27, 2026 17:51
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…olish

- Add normalizeOpenAiBaseUrl() to chatCompletions.ts that strips trailing
  slashes and an optional trailing /v1 segment, preventing the /v1/v1/
  double-append footgun when users paste provider-documented URLs.
- Use normalizeOpenAiBaseUrl in makeChatCompletionsAdapter (endpoint) and
  fetchOpenAiCompatibleModels (/v1/models fetch); drop the now-unused
  trimSlash import from openaiCompatible.ts.
- Remove redundant 'as AiProviderId' cast (M4); drop the unused import.
- Add normalizeOpenAiBaseUrl test coverage in chatCompletions.test.ts and
  a /v1-suffixed base-URL normalization case in openaiCompatible.test.ts.
- Update AiAuthMode baseUrl JSDoc to reflect Ollama + openai-compatible (M1).
- Add OpenAI-Compatible to contextTokens.ts comment for parity (M3).
- Update ProvidersTab base-URL placeholder to https://api.groq.com/openai/v1
  so the UI matches the now-correct /v1-inclusive provider-documented form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115W5vEDNwsWeaeS5PyTFgG
The id stays 'openai-compatible' (stable registry/DB identifier); only the
user-facing display label changes — dropdown, credential card, driver label,
and docs. Protocol descriptions and the filename are unchanged.
Real OpenAI-compatible gateways (OpenCode Zen, OpenRouter, vLLM, …) send
explicit `null` for optional per-chunk fields (`usage: null`,
`tool_calls: null`, `delta.content: null`) on every chunk. The chunk schema
used Type.Optional, which accepts absent-or-value but not null, so parseValue
threw, the frame was dropped in translate()'s catch, and the model's entire
reply silently vanished — reasoning models (GLM, DeepSeek, Qwen, MiniMax)
appeared to 'not reply'. Wrap the optional fields in a nullable() helper so
both absent and null validate. Verified against real gateway frames.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant