Skip to content

Commit 29653b5

Browse files
committed
remove CT-API as Torchy middleman (CodeTorchNET/public-issue-tracker#44)
1 parent 74714ab commit 29653b5

17 files changed

Lines changed: 1056 additions & 217 deletions

‎src/addons/addons/ai-integration/_manifest_entry.js‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ const manifest = {
33
"editorOnly": true,
44
"noTranslations": true,
55
"name": "AI integration into the editor",
6-
"description": "Adds AI integration into the editor including code completion, code generation, sprite generation, background generation, and more.\n\n\nDISCLAIMER: We do not store any request data or your API keys. Your API keys are only sent with requests and remain fully stored locally on your computer. All models we use are free, ensuring no charges apply. However, for added security, we recommend setting restrictions on your API keys.",
6+
"description": "Adds Torchy, the AI assistant, to the editor: it writes, explains and debugs block code and can insert it into your project.\n\nTorchy talks to the AI provider of your choice directly from your browser, so your API keys and chats never pass through a CodeTorch server. Keys stay stored on this computer and are only sent to the provider they belong to. Google Gemini and OpenRouter both offer free models (free ones are marked in the model list); for added security we recommend setting restrictions on your API keys.",
77
"info": [
88
{
9-
"text": "If you need help with creating an API key, check out our guides here: https://blog.codetorch.net/gemini-api-key-for-torchy/ , https://blog.codetorch.net/openrouter-api-key-for-torchy/",
9+
"text": "Need an API key? See our guides: https://blog.codetorch.net/gemini-api-key-for-torchy/ , https://blog.codetorch.net/openrouter-api-key-for-torchy/ . Custom API: any OpenAI-compatible base URL, e.g. https://api.groq.com/openai/v1 or http://localhost:11434/v1 for a local Ollama (set OLLAMA_ORIGINS so it allows browser requests from this site).",
1010
"id": "warning"
1111
}
1212
],
@@ -23,15 +23,43 @@ const manifest = {
2323
"settings": [
2424
{
2525
"id": "GeminiAPIKey",
26-
"name": "Gemini API Key",
26+
"name": "Google Gemini API key",
2727
"type": "long_password",
28-
"default": "",
28+
"default": ""
2929
},
3030
{
3131
"id": "OpenRouterAPIKey",
32-
"name": "OpenRouter API Key",
32+
"name": "OpenRouter API key",
33+
"type": "long_password",
34+
"default": ""
35+
},
36+
{
37+
"id": "CustomEnabled",
38+
"name": "Use a custom OpenAI-compatible API",
39+
"type": "boolean",
40+
"default": false
41+
},
42+
{
43+
"id": "CustomBaseURL",
44+
"name": "Custom API base URL",
45+
"type": "long_string",
46+
"default": "",
47+
"if": {
48+
"settings": {
49+
"CustomEnabled": true
50+
}
51+
}
52+
},
53+
{
54+
"id": "CustomAPIKey",
55+
"name": "Custom API key",
3356
"type": "long_password",
3457
"default": "",
58+
"if": {
59+
"settings": {
60+
"CustomEnabled": true
61+
}
62+
}
3563
}
3664
],
3765
"dynamicDisable": true,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
export const CUSTOM_MODELS_GROUP = 'Your models';
2+
const STORAGE_KEY = 'torchy:customModels';
3+
4+
function defaultStorage () {
5+
try {
6+
return typeof localStorage === 'undefined' ? null : localStorage;
7+
} catch (e) {
8+
return null; // blocked storage throws on access in some browsers
9+
}
10+
}
11+
12+
/** Provider ids and `models/` prefixes aside, a model id is sent exactly as typed. */
13+
export function cleanModelId (raw) {
14+
return String(raw || '').trim().replace(/^models\//, '');
15+
}
16+
17+
/** @returns {Object<string, string[]>} typed-in model ids keyed by provider id */
18+
export function loadCustomModelIds (storage = defaultStorage()) {
19+
if (!storage) return {};
20+
try {
21+
const parsed = JSON.parse(storage.getItem(STORAGE_KEY) || '{}');
22+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
23+
const out = {};
24+
for (const [providerId, ids] of Object.entries(parsed)) {
25+
if (!Array.isArray(ids)) continue;
26+
const clean = ids.map(cleanModelId).filter(Boolean);
27+
if (clean.length) out[providerId] = Array.from(new Set(clean));
28+
}
29+
return out;
30+
} catch (e) {
31+
return {};
32+
}
33+
}
34+
35+
function saveCustomModelIds (byProvider, storage) {
36+
if (!storage) return;
37+
try {
38+
storage.setItem(STORAGE_KEY, JSON.stringify(byProvider));
39+
} catch (e) {
40+
// Storage full or blocked
41+
}
42+
}
43+
44+
/** @returns {import('./models.js').TorchyModel} */
45+
export function makeCustomModel (providerId, modelId) {
46+
const id = cleanModelId(modelId);
47+
return {key: `${providerId}:${id}`, providerId, modelId: id, name: id, free: null, group: CUSTOM_MODELS_GROUP, custom: true};
48+
}
49+
50+
/**
51+
* Remember a typed-in model id for a provider and return its model. Null for a blank id.
52+
*/
53+
export function addCustomModelId (providerId, modelId, storage = defaultStorage()) {
54+
const id = cleanModelId(modelId);
55+
if (!id) return null;
56+
const byProvider = loadCustomModelIds(storage);
57+
const ids = byProvider[providerId] || [];
58+
if (!ids.includes(id)) byProvider[providerId] = ids.concat(id);
59+
saveCustomModelIds(byProvider, storage);
60+
return makeCustomModel(providerId, id);
61+
}
62+
63+
export function removeCustomModelId (providerId, modelId, storage = defaultStorage()) {
64+
const id = cleanModelId(modelId);
65+
const byProvider = loadCustomModelIds(storage);
66+
const ids = (byProvider[providerId] || []).filter(known => known !== id);
67+
if (ids.length) byProvider[providerId] = ids;
68+
else delete byProvider[providerId];
69+
saveCustomModelIds(byProvider, storage);
70+
}
71+
72+
/**
73+
* The fetched models plus the typed-in ones of every active provider. A typed-in id the provider
74+
* also lists is dropped in favour of the listed entry, so nothing shows twice.
75+
* @param {import('./models.js').TorchyModel[]} fetched
76+
* @param {import('./providers.js').Provider[]} providers
77+
*/
78+
export function withCustomModels (fetched, providers, storage = defaultStorage()) {
79+
const byProvider = loadCustomModelIds(storage);
80+
const models = fetched.filter(model => !model.custom);
81+
const known = new Set(models.map(model => model.key));
82+
for (const provider of providers) {
83+
for (const id of byProvider[provider.id] || []) {
84+
const model = makeCustomModel(provider.id, id);
85+
if (known.has(model.key)) continue;
86+
known.add(model.key);
87+
models.push(model);
88+
}
89+
}
90+
return models;
91+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {requestHeaders} from './providers.js';
2+
3+
const MODELS_TIMEOUT_MS = 20000;
4+
5+
// Gemini's model list includes embedding, image, video and speech models that cannot chat.
6+
const GEMINI_NON_CHAT = /embedding|imagen|veo|tts|image|audio|live|robotics|aqa|learnlm|computer-use/i;
7+
8+
/**
9+
* @typedef {object} TorchyModel
10+
* @property {string} key unique across providers: `${providerId}:${modelId}`
11+
* @property {string} providerId
12+
* @property {string} modelId the id sent as `model` in chat requests
13+
* @property {string} name display name
14+
* @property {boolean|null} free true/false when the provider publishes pricing, null otherwise
15+
* @property {string} group dropdown group label within the provider ('' = none)
16+
*/
17+
18+
const isZeroPrice = value => value === undefined || value === null || Number(value) === 0;
19+
20+
/**
21+
* Turn one entry of a `/models` response into a TorchyModel, or null if it is not a chat model.
22+
* @param {import('./providers.js').Provider} provider
23+
* @param {object} raw
24+
* @param {string} group
25+
* @returns {TorchyModel|null}
26+
*/
27+
export function normalizeModel (provider, raw, group = '') {
28+
if (!raw || typeof raw.id !== 'string') return null;
29+
// Gemini's OpenAI layer lists ids as "models/gemini-…"; chat accepts the bare id.
30+
const modelId = raw.id.trim().replace(/^models\//, '');
31+
if (!modelId) return null;
32+
if (provider.id === 'gemini') {
33+
if (!/gemini/i.test(modelId) || GEMINI_NON_CHAT.test(modelId)) return null;
34+
}
35+
36+
let free = null;
37+
if (raw.pricing && typeof raw.pricing === 'object') {
38+
free = isZeroPrice(raw.pricing.prompt) && isZeroPrice(raw.pricing.completion);
39+
}
40+
41+
const name = typeof raw.name === 'string' && raw.name.trim() && raw.name !== raw.id ? raw.name.trim() : modelId;
42+
return {key: `${provider.id}:${modelId}`, providerId: provider.id, modelId, name, free, group};
43+
}
44+
45+
function withTimeout (promise, ms, message) {
46+
let timer;
47+
const timeout = new Promise((_, reject) => {
48+
timer = setTimeout(() => reject(new Error(message)), ms);
49+
});
50+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
51+
}
52+
53+
async function describeFailure (response) {
54+
let detail = '';
55+
try {
56+
const text = await response.text();
57+
try {
58+
const parsed = JSON.parse(text);
59+
const value = parsed && (parsed.error || parsed.message);
60+
detail = typeof value === 'string' ? value : value && value.message ? value.message : '';
61+
} catch (e) {
62+
detail = text;
63+
}
64+
} catch (e) {
65+
detail = '';
66+
}
67+
detail = String(detail || '').trim().slice(0, 200);
68+
if (response.status === 401 || response.status === 403 || /api key/i.test(detail)) return 'the API key was rejected';
69+
return `HTTP ${response.status}${detail ? `: ${detail}` : ''}`;
70+
}
71+
72+
/**
73+
* Fetch the models of one provider. Never throws: a failure comes back as `error`.
74+
* @param {import('./providers.js').Provider} provider
75+
* @param {typeof fetch} [fetchImpl]
76+
* @returns {Promise<{models: TorchyModel[], error: string|null}>}
77+
*/
78+
export async function fetchProviderModels (provider, fetchImpl = fetch) {
79+
const models = [];
80+
const seen = new Set();
81+
let error = null;
82+
83+
for (const query of provider.modelQueries) {
84+
try {
85+
const response = await withTimeout(
86+
fetchImpl(`${provider.baseUrl}${query.path}`, {method: 'GET', headers: requestHeaders(provider)}),
87+
MODELS_TIMEOUT_MS,
88+
'the server did not respond in time'
89+
);
90+
if (!response.ok) throw new Error(await describeFailure(response));
91+
const body = await response.json();
92+
const list = Array.isArray(body) ? body : Array.isArray(body.data) ? body.data : Array.isArray(body.models) ? body.models : [];
93+
for (const raw of list) {
94+
const model = normalizeModel(provider, raw, query.group);
95+
if (!model || seen.has(model.key)) continue;
96+
seen.add(model.key);
97+
models.push(model);
98+
}
99+
} catch (e) {
100+
const message = e && e.message ? e.message : String(e);
101+
error = /Failed to fetch|NetworkError|Load failed/i.test(message) ?
102+
'could not reach the server (is it running, and does it allow browser requests?)' :
103+
message;
104+
break;
105+
}
106+
}
107+
108+
if (!error && models.length === 0) error = 'the server returned no chat models';
109+
return {models, error};
110+
}
111+
112+
/**
113+
* Fetch the models of every active provider in parallel.
114+
* @param {import('./providers.js').Provider[]} providers
115+
* @param {typeof fetch} [fetchImpl]
116+
* @returns {Promise<{models: TorchyModel[], errors: Object<string, string>}>}
117+
*/
118+
export async function fetchAllModels (providers, fetchImpl = fetch) {
119+
const results = await Promise.all(providers.map(provider => fetchProviderModels(provider, fetchImpl)));
120+
const models = [];
121+
const errors = {};
122+
results.forEach((result, index) => {
123+
models.push(...result.models);
124+
if (result.error) errors[providers[index].id] = result.error;
125+
});
126+
return {models, errors};
127+
}
128+
129+
/**
130+
* The model to preselect when a session has none: a Gemini flash model when available (fast, the
131+
* most tested for Torchy's block syntax), otherwise the first model offered.
132+
* @param {TorchyModel[]} models
133+
* @returns {TorchyModel|null}
134+
*/
135+
export function pickDefaultModel (models) {
136+
if (!models.length) return null;
137+
const flash = models.find(m =>
138+
m.providerId === 'gemini' && /flash/i.test(m.modelId) && !/lite|preview|exp|8b|thinking/i.test(m.modelId)
139+
);
140+
return flash || models.find(m => m.providerId === 'gemini' && /flash/i.test(m.modelId)) || models[0];
141+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
2+
export const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai';
3+
4+
const OPENROUTER_HEADERS = {
5+
'HTTP-Referer': 'https://codetorch.net',
6+
'X-Title': 'CodeTorch Torchy'
7+
};
8+
9+
const trimSlash = url => String(url || '').trim()
10+
.replace(/\/+$/, '');
11+
12+
/**
13+
* @typedef {object} ModelQuery
14+
* @property {string} group label of the dropdown group these models go in ('' = provider name only)
15+
* @property {string} path path + query appended to baseUrl
16+
*/
17+
18+
/**
19+
* @typedef {object} Provider
20+
* @property {string} id
21+
* @property {string} name shown as the dropdown group label
22+
* @property {string} baseUrl OpenAI-compatible base URL, no trailing slash
23+
* @property {string} apiKey '' when the provider needs no key
24+
* @property {object} headers extra request headers
25+
* @property {ModelQuery[]} modelQueries queries that fill the dropdown, in display order
26+
*/
27+
28+
/**
29+
* Build the list of active providers from the addon settings, in display order.
30+
* @param {object} settings raw addon settings keyed by setting id
31+
* @returns {Provider[]}
32+
*/
33+
export function buildProviders (settings = {}) {
34+
const providers = [];
35+
36+
const geminiKey = String(settings.GeminiAPIKey || '').trim();
37+
if (geminiKey) {
38+
providers.push({
39+
id: 'gemini',
40+
name: 'Google Gemini',
41+
baseUrl: GEMINI_BASE_URL,
42+
apiKey: geminiKey,
43+
headers: {},
44+
modelQueries: [{group: '', path: '/models'}]
45+
});
46+
}
47+
48+
const openrouterKey = String(settings.OpenRouterAPIKey || '').trim();
49+
if (openrouterKey) {
50+
providers.push({
51+
id: 'openrouter',
52+
name: 'OpenRouter',
53+
baseUrl: OPENROUTER_BASE_URL,
54+
apiKey: openrouterKey,
55+
headers: OPENROUTER_HEADERS,
56+
modelQueries: [
57+
{group: 'Popular for coding', path: '/models?sort=top-weekly&category=programming'},
58+
{group: 'Free', path: '/models?sort=top-weekly&max_price=0'}
59+
]
60+
});
61+
}
62+
63+
const customUrl = trimSlash(settings.CustomBaseURL);
64+
if (settings.CustomEnabled && customUrl) {
65+
providers.push({
66+
id: 'custom',
67+
name: 'Custom API',
68+
baseUrl: customUrl,
69+
apiKey: String(settings.CustomAPIKey || '').trim(),
70+
headers: {},
71+
modelQueries: [{group: '', path: '/models'}]
72+
});
73+
}
74+
75+
return providers;
76+
}
77+
78+
/**
79+
* Headers for a request to a provider. The Authorization header is only sent when there is a key,
80+
* so key-less local servers are not confused by an empty bearer token.
81+
* @param {Provider} provider
82+
* @param {boolean} json add Content-Type for a JSON body
83+
*/
84+
export function requestHeaders (provider, json = false) {
85+
const headers = Object.assign({}, provider.headers);
86+
if (json) headers['Content-Type'] = 'application/json';
87+
if (provider.apiKey) headers.Authorization = `Bearer ${provider.apiKey}`;
88+
return headers;
89+
}

0 commit comments

Comments
 (0)