Skip to content

Commit 9094bce

Browse files
metiu1claude
andcommitted
feat(gateway): unified /v1 for local + cloud models; Open Code sees all; release 0.3.81
/v1/chat/completions now falls through to a cloud proxy when the model isn't local: "provider/model" ids (e.g. ollamacloud/gpt-oss:120b) are forwarded — messages, tools and streaming verbatim — to that provider's OpenAI-compatible endpoint using the key stored in Vortelio. /v1/models lists local + cloud, and the Open Code config now includes every configured cloud model under the one "vortelio" provider, so ALL Vortelio models are usable in Open Code. Also fix /v1/models (no trailing slash) which returned a bogus "model not found" instead of the model list. Cloud model catalogue moved to the cloud package (cloud.ModelChoices/ModelsWithKeys) so server and agent share it. Verified end-to-end against ollamacloud: models listed and a proxied chat returned a valid OpenAI completion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a96fb1d commit 9094bce

11 files changed

Lines changed: 238 additions & 67 deletions

File tree

vortelio-pip/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "vortelio"
7-
version = "0.3.80"
7+
version = "0.3.81"
88
description = "Local-first AI platform. Run LLMs, generate images & video, transcribe audio, create 3D — on your own machine. OpenAI & Ollama API compatible. Apache 2.0."
99
readme = "README.md"
1010
requires-python = ">=3.8"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.80"
1+
__version__ = "0.3.81"

vortelio-pip/src/vortelio_cli/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import urllib.request
1111
from pathlib import Path
1212

13-
VERSION = "0.3.80"
13+
VERSION = "0.3.81"
1414
RELEASE_BASE = os.environ.get(
1515
"VORTELIO_RELEASE_BASE",
1616
f"https://github.com/metiu1/Vortelio/releases/download/v{VERSION}",
9.5 KB
Binary file not shown.

vortelio/internal/agent/agent.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"sync"
1717
"time"
1818

19+
"github.com/vortelio/vortelio/internal/cloud"
1920
"github.com/vortelio/vortelio/internal/config"
2021
)
2122

@@ -864,6 +865,21 @@ func writeOpenCodeConfig(path string) error {
864865
}
865866
}()
866867

868+
// Add every configured cloud model too — the unified /v1 gateway proxies
869+
// "provider/model" ids to the right cloud provider, so Open Code can use
870+
// ALL of Vortelio's models (local + cloud) under the one "vortelio" provider.
871+
for _, cm := range cloud.ModelsWithKeys() {
872+
id := cm.Provider + "/" + cm.Model
873+
label := cm.Label
874+
if label == "" {
875+
label = id
876+
}
877+
models[id] = map[string]string{"name": cm.ProviderName + " · " + label}
878+
if defaultModel == "" {
879+
defaultModel = "vortelio/" + id
880+
}
881+
}
882+
867883
cfg := map[string]interface{}{
868884
"$schema": "https://opencode.ai/config.json",
869885
"provider": map[string]interface{}{
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package cloud
2+
3+
// ModelChoices is the curated model list per provider (model id, display label).
4+
// Users can still send any model string via the API; this is just the picker.
5+
var ModelChoices = map[string][][2]string{
6+
"openai": {
7+
{"gpt-4o", "GPT-4o"},
8+
{"gpt-4o-mini", "GPT-4o mini"},
9+
{"o3-mini", "o3-mini"},
10+
{"o1-mini", "o1-mini"},
11+
},
12+
"anthropic": {
13+
{"claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"},
14+
{"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"},
15+
{"claude-3-opus-20240229", "Claude 3 Opus"},
16+
},
17+
"gemini": {
18+
{"gemini-2.0-flash", "Gemini 2.0 Flash"},
19+
{"gemini-2.5-flash", "Gemini 2.5 Flash"},
20+
{"gemini-2.5-pro", "Gemini 2.5 Pro"},
21+
},
22+
"groq": {
23+
{"llama-3.3-70b-versatile", "Llama 3.3 70B"},
24+
{"llama3-8b-8192", "Llama 3 8B"},
25+
},
26+
"mistral": {
27+
{"mistral-small-latest", "Mistral Small"},
28+
{"mistral-large-latest", "Mistral Large"},
29+
},
30+
"openrouter": {
31+
{"meta-llama/llama-3.1-8b-instruct:free", "Llama 3.1 8B (free)"},
32+
{"anthropic/claude-3.5-sonnet", "Claude 3.5 Sonnet"},
33+
{"openai/gpt-4o", "GPT-4o"},
34+
{"deepseek/deepseek-r1", "DeepSeek R1"},
35+
},
36+
"xai": {
37+
{"grok-2-latest", "Grok 2"},
38+
{"grok-2-vision-latest", "Grok 2 Vision"},
39+
{"grok-beta", "Grok Beta"},
40+
},
41+
"together": {
42+
{"meta-llama/Llama-3.3-70B-Instruct-Turbo", "Llama 3.3 70B Turbo"},
43+
{"Qwen/Qwen2.5-72B-Instruct-Turbo", "Qwen2.5 72B Turbo"},
44+
{"mistralai/Mixtral-8x7B-Instruct-v0.1", "Mixtral 8x7B"},
45+
},
46+
"deepseek": {
47+
{"deepseek-chat", "DeepSeek V3 (chat)"},
48+
{"deepseek-reasoner", "DeepSeek R1 (reasoner)"},
49+
},
50+
"perplexity": {
51+
{"sonar", "Sonar"},
52+
{"sonar-pro", "Sonar Pro"},
53+
{"sonar-reasoning", "Sonar Reasoning"},
54+
},
55+
"ollamacloud": {
56+
{"gpt-oss:120b", "gpt-oss 120B"},
57+
{"deepseek-v3.1:671b", "DeepSeek V3.1 671B"},
58+
{"qwen3-coder:480b", "Qwen3 Coder 480B"},
59+
{"kimi-k2:1t", "Kimi K2 1T"},
60+
},
61+
}
62+
63+
// CloudModel is a ready-to-use cloud model (provider + model id + label).
64+
type CloudModel struct {
65+
Provider string
66+
ProviderName string
67+
Model string
68+
Label string
69+
}
70+
71+
// ModelsWithKeys returns the cloud models the user can actually use — every
72+
// provider that has at least one saved API key. Used by the unified /v1 gateway
73+
// and the Open Code config generator.
74+
func ModelsWithKeys() []CloudModel {
75+
var out []CloudModel
76+
for _, p := range Providers {
77+
if LoadKey(p.ID) == "" {
78+
continue
79+
}
80+
choices := ModelChoices[p.ID]
81+
if len(choices) == 0 {
82+
choices = [][2]string{{p.DefaultModel, p.DefaultModel}}
83+
}
84+
for _, c := range choices {
85+
out = append(out, CloudModel{Provider: p.ID, ProviderName: p.Name, Model: c[0], Label: c[1]})
86+
}
87+
}
88+
return out
89+
}
90+
91+
// ChatCompletionsURL returns a provider's OpenAI-compatible /chat/completions
92+
// endpoint, suitable for a transparent proxy that forwards a full OpenAI request
93+
// (messages, tools, stream). Anthropic and Gemini expose dedicated
94+
// OpenAI-compatible endpoints; the rest already point there.
95+
func ChatCompletionsURL(p Provider) string {
96+
switch p.ID {
97+
case "anthropic":
98+
return "https://api.anthropic.com/v1/chat/completions"
99+
case "gemini":
100+
return "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
101+
default:
102+
return p.BaseURL
103+
}
104+
}

vortelio/internal/server/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ func NewMux() *http.ServeMux {
227227
mux.HandleFunc("/api/blobs/", ca(handleOllamaBlobs))
228228

229229
// ── OpenAI-compatible API ─────────────────────────────────────────────────
230-
mux.HandleFunc("/v1/models", ca(handleOpenAIModelByID))
230+
mux.HandleFunc("/v1/models", ca(handleOpenAIModels))
231231
mux.HandleFunc("/v1/models/", ca(handleOpenAIModelByID))
232232
mux.HandleFunc("/v1/chat/completions", ca(withRateLimit(generateLimiter, handleOpenAIChatCompletions)))
233233
mux.HandleFunc("/v1/completions", ca(withRateLimit(generateLimiter, handleOpenAICompletions)))

vortelio/internal/server/server_cloud.go

Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -17,65 +17,9 @@ import (
1717
// cloud package; requests are made directly to the provider with the user's key
1818
// and streamed back as a uniform SSE stream of {"delta": "..."} events.
1919

20-
// cloudModelChoices is the curated model list shown in the picker per provider.
21-
// Users can still send any model string via the API; this is just for the UI.
22-
var cloudModelChoices = map[string][][2]string{
23-
"openai": {
24-
{"gpt-4o", "GPT-4o"},
25-
{"gpt-4o-mini", "GPT-4o mini"},
26-
{"o3-mini", "o3-mini"},
27-
{"o1-mini", "o1-mini"},
28-
},
29-
"anthropic": {
30-
{"claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"},
31-
{"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"},
32-
{"claude-3-opus-20240229", "Claude 3 Opus"},
33-
},
34-
"gemini": {
35-
{"gemini-2.0-flash", "Gemini 2.0 Flash"},
36-
{"gemini-2.5-flash", "Gemini 2.5 Flash"},
37-
{"gemini-2.5-pro", "Gemini 2.5 Pro"},
38-
},
39-
"groq": {
40-
{"llama-3.3-70b-versatile", "Llama 3.3 70B"},
41-
{"llama3-8b-8192", "Llama 3 8B"},
42-
},
43-
"mistral": {
44-
{"mistral-small-latest", "Mistral Small"},
45-
{"mistral-large-latest", "Mistral Large"},
46-
},
47-
"openrouter": {
48-
{"meta-llama/llama-3.1-8b-instruct:free", "Llama 3.1 8B (free)"},
49-
{"anthropic/claude-3.5-sonnet", "Claude 3.5 Sonnet"},
50-
{"openai/gpt-4o", "GPT-4o"},
51-
{"deepseek/deepseek-r1", "DeepSeek R1"},
52-
},
53-
"xai": {
54-
{"grok-2-latest", "Grok 2"},
55-
{"grok-2-vision-latest", "Grok 2 Vision"},
56-
{"grok-beta", "Grok Beta"},
57-
},
58-
"together": {
59-
{"meta-llama/Llama-3.3-70B-Instruct-Turbo", "Llama 3.3 70B Turbo"},
60-
{"Qwen/Qwen2.5-72B-Instruct-Turbo", "Qwen2.5 72B Turbo"},
61-
{"mistralai/Mixtral-8x7B-Instruct-v0.1", "Mixtral 8x7B"},
62-
},
63-
"deepseek": {
64-
{"deepseek-chat", "DeepSeek V3 (chat)"},
65-
{"deepseek-reasoner", "DeepSeek R1 (reasoner)"},
66-
},
67-
"perplexity": {
68-
{"sonar", "Sonar"},
69-
{"sonar-pro", "Sonar Pro"},
70-
{"sonar-reasoning", "Sonar Reasoning"},
71-
},
72-
"ollamacloud": {
73-
{"gpt-oss:120b", "gpt-oss 120B"},
74-
{"deepseek-v3.1:671b", "DeepSeek V3.1 671B"},
75-
{"qwen3-coder:480b", "Qwen3 Coder 480B"},
76-
{"kimi-k2:1t", "Kimi K2 1T"},
77-
},
78-
}
20+
// The curated per-provider model list lives in the cloud package as
21+
// cloud.ModelChoices so the unified /v1 gateway and the Open Code config
22+
// generator can share it.
7923

8024
// GET /api/cloud/providers
8125
// Lists providers, whether a key is stored, and the model choices.
@@ -95,7 +39,7 @@ func CloudModelsForCLI() []CLICloudModel {
9539
if cloud.LoadKey(p.ID) == "" {
9640
continue
9741
}
98-
choices := cloudModelChoices[p.ID]
42+
choices := cloud.ModelChoices[p.ID]
9943
if len(choices) == 0 {
10044
choices = [][2]string{{p.DefaultModel, p.DefaultModel}}
10145
}
@@ -230,7 +174,7 @@ func handleCloudProviders(w http.ResponseWriter, r *http.Request) {
230174
out := make([]providerOut, 0, len(cloud.Providers))
231175
for _, p := range cloud.Providers {
232176
models := []modelOut{}
233-
if choices, ok := cloudModelChoices[p.ID]; ok {
177+
if choices, ok := cloud.ModelChoices[p.ID]; ok {
234178
for _, c := range choices {
235179
models = append(models, modelOut{ID: c[0], Label: c[1]})
236180
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package server
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/vortelio/vortelio/internal/cloud"
10+
)
11+
12+
// proxyCloudChatCompletion turns /v1/chat/completions into a unified gateway:
13+
// when the requested model isn't a local one, it's treated as "provider/model"
14+
// (e.g. "anthropic/claude-3-5-haiku-20241022") and forwarded — verbatim, tools
15+
// and streaming included — to that cloud provider's OpenAI-compatible endpoint
16+
// using the API key stored in Vortelio. Returns true if it handled the request.
17+
func proxyCloudChatCompletion(w http.ResponseWriter, modelID string, rawBody []byte) bool {
18+
slash := strings.Index(modelID, "/")
19+
if slash <= 0 || slash >= len(modelID)-1 {
20+
return false // not a "provider/model" id
21+
}
22+
provID := modelID[:slash]
23+
realModel := modelID[slash+1:]
24+
25+
p, ok := cloud.FindProvider(provID)
26+
if !ok {
27+
return false // unknown provider — let the caller 404
28+
}
29+
keys := cloud.LoadKeys(provID)
30+
if len(keys) == 0 {
31+
jsonError(w, 402, "no API key configured for cloud provider "+provID+" — add one in Vortelio (Cloud Models)")
32+
return true
33+
}
34+
35+
// Rewrite only the model field; keep messages, tools, stream, etc. intact.
36+
var body map[string]interface{}
37+
if err := json.Unmarshal(rawBody, &body); err != nil {
38+
jsonError(w, 400, "invalid JSON: "+err.Error())
39+
return true
40+
}
41+
body["model"] = realModel
42+
outBody, _ := json.Marshal(body)
43+
44+
preq, err := http.NewRequest(http.MethodPost, cloud.ChatCompletionsURL(p), bytes.NewReader(outBody))
45+
if err != nil {
46+
jsonError(w, 500, err.Error())
47+
return true
48+
}
49+
preq.Header.Set("Content-Type", "application/json")
50+
preq.Header.Set("Authorization", "Bearer "+keys[0])
51+
// Anthropic's OpenAI-compatible endpoint still wants an API version header.
52+
if p.ID == "anthropic" {
53+
preq.Header.Set("anthropic-version", "2023-06-01")
54+
}
55+
56+
resp, err := http.DefaultClient.Do(preq)
57+
if err != nil {
58+
jsonError(w, 502, "cloud request failed: "+err.Error())
59+
return true
60+
}
61+
defer resp.Body.Close()
62+
63+
// Relay status, content-type and the (possibly streamed) body verbatim.
64+
if ct := resp.Header.Get("Content-Type"); ct != "" {
65+
w.Header().Set("Content-Type", ct)
66+
}
67+
w.Header().Set("Cache-Control", "no-cache")
68+
w.WriteHeader(resp.StatusCode)
69+
70+
flusher, _ := w.(http.Flusher)
71+
buf := make([]byte, 8192)
72+
for {
73+
n, rerr := resp.Body.Read(buf)
74+
if n > 0 {
75+
w.Write(buf[:n])
76+
if flusher != nil {
77+
flusher.Flush()
78+
}
79+
}
80+
if rerr != nil {
81+
break
82+
}
83+
}
84+
return true
85+
}

vortelio/internal/server/server_openai_compat.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package server
33
import (
44
"encoding/json"
55
"fmt"
6+
"io"
67
"net/http"
78
"strings"
89
"time"
910

11+
"github.com/vortelio/vortelio/internal/cloud"
1012
"github.com/vortelio/vortelio/internal/hub"
1113
"github.com/vortelio/vortelio/internal/runtime"
1214
)
@@ -76,6 +78,16 @@ func handleOpenAIModels(w http.ResponseWriter, r *http.Request) {
7678
OwnedBy: "vortelio",
7779
})
7880
}
81+
// Also advertise configured cloud models (provider/model), so the /v1
82+
// endpoint is a unified catalogue of everything usable in Vortelio.
83+
for _, cm := range cloud.ModelsWithKeys() {
84+
data = append(data, oaiModel{
85+
ID: cm.Provider + "/" + cm.Model,
86+
Object: "model",
87+
Created: time.Now().Unix(),
88+
OwnedBy: cm.Provider,
89+
})
90+
}
7991
if data == nil {
8092
data = []oaiModel{}
8193
}
@@ -117,8 +129,13 @@ func handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Request) {
117129
jsonError(w, 405, "POST only")
118130
return
119131
}
132+
rawBody, err := io.ReadAll(r.Body)
133+
if err != nil {
134+
jsonError(w, 400, "could not read body: "+err.Error())
135+
return
136+
}
120137
var req openAIChatRequest
121-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
138+
if err := json.Unmarshal(rawBody, &req); err != nil {
122139
jsonError(w, 400, "invalid JSON: "+err.Error())
123140
return
124141
}
@@ -129,6 +146,11 @@ func handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Request) {
129146

130147
model, err := resolveModel(req.Model)
131148
if err != nil {
149+
// Not a local model — try routing it to a configured cloud provider so
150+
// /v1 is a unified gateway (local + cloud). Model ids are "provider/model".
151+
if proxyCloudChatCompletion(w, req.Model, rawBody) {
152+
return
153+
}
132154
jsonError(w, 404, err.Error())
133155
return
134156
}

0 commit comments

Comments
 (0)