diff --git a/apis/libai.lua b/apis/libai.lua index e6bdd6a..1763ce0 100644 --- a/apis/libai.lua +++ b/apis/libai.lua @@ -10,65 +10,12 @@ local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5; local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id'; local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent'; -local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - -local function base64encode(s) - local pad = (3 - #s % 3) % 3; - s = s .. string.rep('\0', pad); - local r = {}; - for i = 1, #s, 3 do - local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2); - local n = a * 65536 + b * 256 + c; - r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1) - .. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1) - .. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) - .. B64:sub(n % 64 + 1, n % 64 + 1); - end - local result = table.concat(r); - if pad > 0 then - result = result:sub(1, #result - pad) .. string.rep('=', pad); - end - return result; -end - -local function trimTrailingSlash(s) - return (s:gsub('/+$', '')); -end - -local function urlEncode(s) - return (tostring(s):gsub('[^%w%-_%.~]', function(c) - return string.format('%%%02X', string.byte(c)); - end)); -end +local createHttp = require('/apis/libhttp'); local function isBlank(s) return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil; end -local function queryString(params) - local parts = {}; - for _, item in ipairs(params) do - if not isBlank(item[2]) then - parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]); - end - end - if #parts == 0 then return ''; end - return '?' .. table.concat(parts, '&'); -end - -local function readAllAndClose(response) - local body = response.readAll(); - response.close(); - return body; -end - -local function statusCode(response) - if response.getResponseCode then - return response.getResponseCode(); - end - return nil; -end - local function extractTextParts(parts) if type(parts) ~= 'table' then return ''; @@ -210,6 +157,7 @@ local function createAi(opts) local eventloopFactory = opts.eventloop or require('/apis/eventloop'); local nowFunc = opts.now or nowSeconds; local osLib = opts.os or os; + local httpClient = opts.httpClient or createHttp({ http = httpLib }); local api = {}; @@ -278,7 +226,7 @@ local function createAi(opts) local directory = options.directory or settingsLib.get('opencc.directory'); local providerId, modelId = resolveModel(options); return { - url = trimTrailingSlash(url), + url = httpClient.trimTrailingSlash(url), username = username, password = password, directory = directory, @@ -333,6 +281,29 @@ local function createAi(opts) return type(message.info.time) == 'table' and message.info.time.completed ~= nil; end + local function errorMessage(errorInfo) + if type(errorInfo) ~= 'table' then return nil; end + if type(errorInfo.data) == 'table' and type(errorInfo.data.message) == 'string' then + return errorInfo.data.message; + end + if type(errorInfo.message) == 'string' then + return errorInfo.message; + end + if type(errorInfo.name) == 'string' then + return errorInfo.name; + end + return 'unknown assistant error'; + end + + local function sessionStatusText(status) + if type(status) ~= 'table' then return nil; end + if type(status.type) ~= 'string' then return nil; end + if status.type == 'retry' then + return 'retry #' .. tostring(status.attempt or '?') .. ': ' .. tostring(status.message or 'unknown error'); + end + return status.type; + end + local function decodeMessage(value) local decoded = value; if type(value) == 'string' then @@ -409,16 +380,30 @@ local function createAi(opts) local reply = decoded and extractTextParts(decoded.parts) or ''; local complete = decoded and isMessageComplete(decoded) or false; local matchedId = decoded and type(decoded.info) == 'table' and decoded.info.id or 'nil'; + local assistantError = decoded and type(decoded.info) == 'table' and errorMessage(decoded.info.error) or nil; log('poll #' .. tostring(attemptCount) .. ': messages=' .. tostring(#messages) .. ', found=' .. tostring(matchedId) .. ', complete=' .. tostring(complete) - .. ', text=' .. tostring(reply ~= '')); + .. ', text=' .. tostring(reply ~= '') + .. ', error=' .. tostring(assistantError ~= nil)); + if assistantError then + return finish(false, 'erreur assistant: ' .. assistantError); + end if decoded and reply ~= '' and complete then log('async reply completed'); return finish(true, { reply = reply, sessionId = sessionId, messageId = messageId }); end if nowFunc() >= deadline then + local statusBody, statusCodeValue = doGet(cfg, '/session/status'); + if statusBody and (not statusCodeValue or statusCodeValue == 200) then + local statuses = textutils.unserializeJSON(statusBody); + local statusText = type(statuses) == 'table' and sessionStatusText(statuses[sessionId]) or nil; + if statusText then + log('session status at timeout: ' .. statusText); + return finish(false, 'delai depasse en attendant la reponse AI (status: ' .. statusText .. ')'); + end + end return finish(false, 'delai depasse en attendant la reponse AI'); end loop.setTimeout(attempt, cfg.pollIntervalSeconds); @@ -429,46 +414,12 @@ local function createAi(opts) return resultOk, resultValue; end - local function buildHeaders(cfg) - local headers = { - ['Content-Type'] = 'application/json', - ['Accept'] = 'application/json', - }; - if cfg.password and cfg.password ~= '' then - headers['Authorization'] = 'Basic ' .. base64encode(cfg.username .. ':' .. cfg.password); - end - return headers; - end - - local function callHttp(method, request) - local ok, response, httpErr, errorResponse = pcall(httpLib[method], request); - if not ok then - return nil, 'http ' .. method .. ' threw: ' .. tostring(response); - end - response = response or errorResponse; - if not response then - return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error'); - end - local code = statusCode(response); - local body = readAllAndClose(response); - return body, code; - end - function doGet(cfg, path) - return callHttp('get', { - url = cfg.url .. path, - headers = buildHeaders(cfg), - timeout = cfg.timeoutSeconds, - }); + return httpClient.getJson(cfg, path); end function doPost(cfg, path, payload) - return callHttp('post', { - url = cfg.url .. path, - body = textutils.serializeJSON(payload), - headers = buildHeaders(cfg), - timeout = cfg.timeoutSeconds, - }); + return httpClient.postJson(cfg, path, payload); end local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log) @@ -494,7 +445,7 @@ local function createAi(opts) end local function listSessionsWithDirectory(cfg, directory) - return doGet(cfg, '/session' .. queryString({ { 'directory', directory } })); + return doGet(cfg, '/session' .. httpClient.queryString({ { 'directory', directory } })); end local function decodeSessionList(body, log) @@ -609,8 +560,8 @@ local function createAi(opts) promptWithContext = buildPromptWithCallerContext(prompt, osLib); end - if not (cfg.providerID and cfg.modelID) then - log('provider/model unset; using blocking message endpoint'); + if options.blocking == true then + log('using blocking message endpoint'); return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log); end diff --git a/apis/libhttp.lua b/apis/libhttp.lua new file mode 100644 index 0000000..76e585a --- /dev/null +++ b/apis/libhttp.lua @@ -0,0 +1,122 @@ +local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +local function base64encode(s) + local pad = (3 - #s % 3) % 3; + s = s .. string.rep('\0', pad); + local r = {}; + for i = 1, #s, 3 do + local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2); + local n = a * 65536 + b * 256 + c; + r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1) + .. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1) + .. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) + .. B64:sub(n % 64 + 1, n % 64 + 1); + end + local result = table.concat(r); + if pad > 0 then + result = result:sub(1, #result - pad) .. string.rep('=', pad); + end + return result; +end + +local function trimTrailingSlash(s) + return (s:gsub('/+$', '')); +end + +local function urlEncode(s) + return (tostring(s):gsub('[^%w%-_%.~]', function(c) + return string.format('%%%02X', string.byte(c)); + end)); +end + +local function isBlank(s) + return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil; +end + +local function queryString(params) + local parts = {}; + for _, item in ipairs(params) do + if not isBlank(item[2]) then + parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]); + end + end + if #parts == 0 then return ''; end + return '?' .. table.concat(parts, '&'); +end + +local function readAllAndClose(response) + local body = response.readAll(); + response.close(); + return body; +end + +local function statusCode(response) + if response.getResponseCode then + return response.getResponseCode(); + end + return nil; +end + +local function createHttp(opts) + opts = opts or {}; + local httpLib = opts.http or http; + local textutilsLib = opts.textutils or textutils; + + local api = { + base64encode = base64encode, + trimTrailingSlash = trimTrailingSlash, + urlEncode = urlEncode, + queryString = queryString, + }; + + function api.basicAuth(username, password) + return 'Basic ' .. base64encode(tostring(username or '') .. ':' .. tostring(password or '')); + end + + function api.jsonHeaders(options) + options = options or {}; + local headers = { + ['Content-Type'] = 'application/json', + ['Accept'] = 'application/json', + }; + if options.password and options.password ~= '' then + headers['Authorization'] = api.basicAuth(options.username, options.password); + end + return headers; + end + + function api.call(method, request) + local ok, response, httpErr, errorResponse = pcall(httpLib[method], request); + if not ok then + return nil, 'http ' .. method .. ' threw: ' .. tostring(response); + end + response = response or errorResponse; + if not response then + return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error'); + end + local code = statusCode(response); + local body = readAllAndClose(response); + return body, code; + end + + function api.getJson(cfg, path) + return api.call('get', { + url = cfg.url .. path, + headers = api.jsonHeaders(cfg), + timeout = cfg.timeoutSeconds, + }); + end + + function api.postJson(cfg, path, payload) + return api.call('post', { + url = cfg.url .. path, + body = textutilsLib.serializeJSON(payload), + headers = api.jsonHeaders(cfg), + timeout = cfg.timeoutSeconds, + }); + end + + return api; +end + +return createHttp; diff --git a/docs/opencode_api.md b/docs/opencode_api.md index c7ac0d2..2233e2b 100644 --- a/docs/opencode_api.md +++ b/docs/opencode_api.md @@ -144,7 +144,7 @@ Abort a running generation. ### `POST /session/:id/prompt_async` -Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the request body so the submitted message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`. +Fire-and-forget variant. Returns `204` immediately and starts generation in the background. Include `messageID` in the request body so the submitted user message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`. **Request body:** ```json @@ -158,9 +158,9 @@ Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the r } ``` -Unlike `/message`, `model` is **not** optional in practice — omitting it causes the request to be accepted (`204`) without triggering generation, so the assistant message never appears. `ai` only uses this async endpoint when `opencc.provider_id` and `opencc.model_id` are configured; otherwise it falls back to blocking `POST /session/:id/message`. +`agent` and `model` are optional. Omit `model` to use the server/session default model, or include it to force a specific provider/model for this prompt. -When async mode is available, `ai` uses this endpoint to avoid `504` failures from the blocking `/message` endpoint when the LLM takes longer than one HTTP request timeout. The submitted `messageID` identifies the user or assistant message depending on the opencode response shape; `ai` polls `GET /session/:id/message` and reads the completed assistant message. If `opencc.agent` or `--agent ` is set, `ai` includes it as `agent` in the request body. +`ai` uses this endpoint by default to avoid `504` failures from the blocking `/message` endpoint when the LLM takes longer than one HTTP request timeout. The submitted `messageID` identifies the user message; `ai` polls `GET /session/:id/message` and reads the completed assistant message after it. If `opencc.agent` or `--agent ` is set, `ai` includes it as `agent` in the request body. If generation fails in the background, opencode records the failure on the assistant message or session event stream; `ai` surfaces assistant message errors while polling. --- diff --git a/docs/opencode_server_guide.md b/docs/opencode_server_guide.md index 1fc72e6..72f2a2e 100644 --- a/docs/opencode_server_guide.md +++ b/docs/opencode_server_guide.md @@ -116,7 +116,7 @@ Optional — scope `ai sessions` to a specific opencode project directory. If om set opencc.directory /Users/garm/trap/cc-libs ``` -Optional but recommended: pick the provider and model. When both are set, `ai` posts to `/session/:id/prompt_async` and polls for completion. Without them, `ai` falls back to blocking `/session/:id/message`, which can use the server default model but is more exposed to HTTP timeouts: +Optional: pick the provider and model for requests from this computer. If omitted, `ai` still posts to `/session/:id/prompt_async`; opencode uses the server/session default model: ```sh set opencc.provider_id anthropic @@ -169,6 +169,7 @@ Set settings inside the harness before running, or inject them via the test API. | `erreur message: HTTP 401` | Wrong password | Check `opencc.password` matches `OPENCODE_SERVER_PASSWORD` | | `missing prompt` | No prompt was passed | Run `ai ` or `ai ping` | | `session introuvable; lance: ai new ` | Session was deleted or server restarted | Run `ai new ` | -| `erreur message: HTTP 504` | AI took too long | Retry; consider a faster model | -| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds` or check opencode logs | +| `erreur message: HTTP 504` | Blocking mode AI call took too long | Retry; prefer the default async mode | +| `erreur assistant: ...` | Opencode accepted the async prompt but generation failed | Check the provider/model/agent and opencode logs | +| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds`; use `ai --verbose` and check opencode logs | | `reponse vide` | Reply had no text parts | Check opencode logs | diff --git a/manifest.json b/manifest.json index 3684c63..2cfc105 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.8.14", + "version": "0.8.15", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index b3ecb9f..7016312 100644 --- a/packages/index.json +++ b/packages/index.json @@ -5,8 +5,8 @@ "trapos-boot": "0.3.2", "trapos-net": "0.3.0", "trapos-ui": "0.2.2", - "trapos-ai": "0.6.12", + "trapos-ai": "0.6.13", "trapos-sandbox": "0.2.2", - "trapos": "0.8.14" + "trapos": "0.8.15" } } diff --git a/packages/trapos-ai/ccpm.json b/packages/trapos-ai/ccpm.json index 5723bee..61d76e4 100644 --- a/packages/trapos-ai/ccpm.json +++ b/packages/trapos-ai/ccpm.json @@ -1,10 +1,11 @@ { "name": "trapos-ai", - "version": "0.6.12", + "version": "0.6.13", "description": "TrapOS AI client for opencode serve", "dependencies": ["trapos-core"], "files": [ "apis/libai.lua", + "apis/libhttp.lua", "apis/libtrapgpt.lua", "programs/ai.lua", "programs/trapgpt.lua" diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index b796b1d..d8b4cc4 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.8.14", + "version": "0.8.15", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/tests/ai.lua b/tests/ai.lua index 7ab1f1d..6a03fe5 100644 --- a/tests/ai.lua +++ b/tests/ai.lua @@ -161,6 +161,18 @@ local function assistantMessage(id, reply, completed) }; end +local function assistantErrorMessage(id, message) + return { + info = { + id = id, + role = 'assistant', + error = { name = 'UnknownError', data = { message = message } }, + time = { completed = 1 }, + }, + parts = {}, + }; +end + local function postedText(call) local body = textutils.unserializeJSON(call.body); return body.parts[1].text; @@ -358,7 +370,7 @@ testlib.test('ask creates session then sends message when no session_id', functi testlib.assertEquals(result.sessionId, 'ses_new'); testlib.assertEquals(#httpStub.postCalls, 2); testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session', 1, true) ~= nil); - testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_new/message', 1, true) ~= nil); + testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_new/prompt_async', 1, true) ~= nil); end); testlib.test('ask creates cc-ai titled sessions', function() @@ -419,7 +431,7 @@ testlib.test('ask reuses existing session_id without creating a new session', fu testlib.assertTrue(ok); testlib.assertEquals(#httpStub.postCalls, 1); - testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/message', 1, true) ~= nil); + testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/prompt_async', 1, true) ~= nil); end); testlib.test('ask reuses custom sessionSettingKey without creating a new session', function() @@ -438,10 +450,10 @@ testlib.test('ask reuses custom sessionSettingKey without creating a new session testlib.assertTrue(ok); testlib.assertEquals(#httpStub.postCalls, 1); - testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/message', 1, true) ~= nil); + testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/prompt_async', 1, true) ~= nil); end); -testlib.test('ask falls back to blocking message when model is unset', function() +testlib.test('ask can use blocking message when explicitly requested', function() local httpStub = fakeHttp( { sessionResp('ses_blocking'), messageResp('reply') }, {} @@ -449,13 +461,29 @@ testlib.test('ask falls back to blocking message when model is unset', function( local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); + local ok, result = ai.ask('hello', { blocking = true }); + + testlib.assertTrue(ok, tostring(result)); + testlib.assertEquals(result.reply, 'reply'); + testlib.assertEquals(#httpStub.postCalls, 2); + testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_blocking/message', 1, true) ~= nil); + testlib.assertEquals(#httpStub.getCalls, 0); +end); + +testlib.test('ask uses async prompt when model is unset', function() + local httpStub = fakeHttp( + { sessionResp('ses_async'), messageResp('reply') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAi({ http = httpStub, settings = settingsStub }); + local ok, result = ai.ask('hello'); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'reply'); testlib.assertEquals(#httpStub.postCalls, 2); - testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_blocking/message', 1, true) ~= nil); - testlib.assertEquals(#httpStub.getCalls, 0); + testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_async/prompt_async', 1, true) ~= nil); end); testlib.test('ask wraps prompt with caller context', function() @@ -834,6 +862,29 @@ testlib.test('ask polling tolerates assistant message without parts', function() testlib.assertEquals(#httpStub.getCalls, 2); end); +testlib.test('ask polling reports assistant errors', function() + local httpStub = fakeHttp( + { sessionResp('ses_1'), asyncResp() }, + { + messageListResp({ userMessage('msg_1', 'hello'), assistantErrorMessage('msg_2', 'bad model') }), + } + ); + local settingsStub = fakeAsyncSettings(); + local elFactory = fakeEventloopFactory(); + local ai = createAi({ + http = httpStub, + settings = settingsStub, + now = function() return 10; end, + eventloop = elFactory, + }); + + local ok, err = ai.ask('hello', { messageId = 'msg_1' }); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'erreur assistant: bad model', 1, true) ~= nil); + testlib.assertEquals(#httpStub.getCalls, 1); +end); + testlib.test('ask polling default timeout allows ten minute replies', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, @@ -938,7 +989,7 @@ testlib.test('ask polling times out', function() testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil); - testlib.assertEquals(#httpStub.getCalls, 2); + testlib.assertEquals(#httpStub.getCalls, 3); testlib.assertTrue(elState.lastLoop.inspect().stopped); testlib.assertEquals(#elState.lastLoop.inspect().pending, 0); end); @@ -977,7 +1028,7 @@ testlib.test('ask caps polling timeout at ten minutes', function() testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil); - testlib.assertEquals(#httpStub.getCalls, 3); + testlib.assertEquals(#httpStub.getCalls, 4); testlib.assertEquals(now, 600); end);