From dc09639cb15d97d497ce80c3fc8554a54a796c62 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Mon, 8 Jun 2026 21:05:31 +0200 Subject: [PATCH] feat(ai): add hello world proxy client --- apis/libaihelloworld.lua | 114 +++++++++++++++++++++++++ programs/ai-helloworld.lua | 46 ++++++++++ tests/ai-helloworld.lua | 168 +++++++++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 apis/libaihelloworld.lua create mode 100644 programs/ai-helloworld.lua create mode 100644 tests/ai-helloworld.lua diff --git a/apis/libaihelloworld.lua b/apis/libaihelloworld.lua new file mode 100644 index 0000000..26ece99 --- /dev/null +++ b/apis/libaihelloworld.lua @@ -0,0 +1,114 @@ +local _VERSION = '0.1.0'; + +local DEFAULT_PROMPT = 'reply with exactly: pong'; + +local function trimTrailingSlash(value) + return (value:gsub('/+$', '')); +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 mapStatusError(code) + if code == 401 then return 'token invalide'; end + if code == 413 then return 'prompt trop long'; end + if code == 502 then return 'agent indisponible'; end + if code == 504 then return 'agent trop lent'; end + if code then return 'erreur proxy: HTTP ' .. tostring(code); end + return 'erreur proxy inconnue'; +end + +local function createAiHelloWorld(opts) + opts = opts or {}; + + local httpLib = opts.http or http; + local settingsLib = opts.settings or settings; + local prompt = opts.prompt or DEFAULT_PROMPT; + + local api = {}; + + function api.version() + return _VERSION; + end + + function api.clearSession() + settingsLib.unset('opencc.session_id'); + if settingsLib.save then settingsLib.save(); end + end + + function api.askHello(options) + options = options or {}; + + local proxyUrl = options.proxyUrl or settingsLib.get('opencc.proxy_url'); + if not proxyUrl or proxyUrl == '' then + return false, 'missing opencc.proxy_url; run: settings set opencc.proxy_url '; + end + + local token = options.proxyToken or settingsLib.get('opencc.proxy_token'); + if not token or token == '' then + return false, 'missing opencc.proxy_token; run: settings set opencc.proxy_token '; + end + + local sessionId = options.sessionId; + if sessionId == nil then + sessionId = settingsLib.get('opencc.session_id'); + end + + local body = { prompt = prompt }; + if sessionId and sessionId ~= '' then + body.sessionId = sessionId; + end + + local response = httpLib.post( + trimTrailingSlash(proxyUrl) .. '/ask', + textutils.serializeJSON(body), + { + ['Authorization'] = 'Bearer ' .. token, + ['Content-Type'] = 'application/json', + } + ); + + if not response then + return false, 'proxy injoignable. Verifie opencc.proxy_url et http.rules'; + end + + local code = statusCode(response); + local responseBody = readAllAndClose(response); + if code and code ~= 200 then + return false, mapStatusError(code); + end + + local decoded = textutils.unserializeJSON(responseBody or ''); + if type(decoded) ~= 'table' then + return false, 'reponse proxy invalide'; + end + if type(decoded.reply) ~= 'string' then + return false, 'reponse proxy sans reply'; + end + + if type(decoded.sessionId) == 'string' and decoded.sessionId ~= '' then + settingsLib.set('opencc.session_id', decoded.sessionId); + if settingsLib.save then settingsLib.save(); end + end + + return true, { + reply = decoded.reply, + sessionId = decoded.sessionId, + truncated = decoded.truncated == true, + }; + end + + return api; +end + +return createAiHelloWorld; diff --git a/programs/ai-helloworld.lua b/programs/ai-helloworld.lua new file mode 100644 index 0000000..ddeff23 --- /dev/null +++ b/programs/ai-helloworld.lua @@ -0,0 +1,46 @@ +local _VERSION = '0.1.0'; + +local createAiHelloWorld = require('/apis/libaihelloworld'); + +local args = table.pack(...); +local command = args[1]; + +local function printUsage() + print('ai-helloworld usage:'); + print(); + print(' ai-helloworld'); + print(' ai-helloworld --new'); + print(' ai-helloworld --version'); + print(' ai-helloworld --help'); + print(); + print('settings required:'); + print(' opencc.proxy_url'); + print(' opencc.proxy_token'); +end + +if command == '--version' or command == '-version' or command == 'version' then + print('ai-helloworld v' .. _VERSION); + return; +end + +if command == '--help' or command == '-help' or command == 'help' then + printUsage(); + return; +end + +local ai = createAiHelloWorld(); + +if command == '--new' then + ai.clearSession(); +elseif command ~= nil and command ~= '' then + printUsage(); + return; +end + +local ok, result = ai.askHello(); +if not ok then + print(result); + return; +end + +print(result.reply); diff --git a/tests/ai-helloworld.lua b/tests/ai-helloworld.lua new file mode 100644 index 0000000..3aac4bf --- /dev/null +++ b/tests/ai-helloworld.lua @@ -0,0 +1,168 @@ +local createLibTest = require('/apis/libtest'); +local createAiHelloWorld = require('/apis/libaihelloworld'); + +local testlib = createLibTest({ ... }); + +local function fakeSettings(initial) + local values = initial or {}; + local saveCount = 0; + + return { + get = function(key) + return values[key]; + end, + set = function(key, value) + values[key] = value; + end, + unset = function(key) + values[key] = nil; + end, + save = function() + saveCount = saveCount + 1; + end, + values = values, + saveCount = function() + return saveCount; + end, + }; +end + +local function response(code, body) + return { + getResponseCode = function() + return code; + end, + readAll = function() + return body; + end, + close = function() end, + }; +end + +local function fakeHttp(result) + local calls = {}; + return { + post = function(url, body, headers) + calls[#calls + 1] = { url = url, body = body, headers = headers }; + if type(result) == 'function' then + return result(url, body, headers); + end + return result; + end, + calls = calls, + }; +end + +testlib.test('askHello posts ping prompt and saves session id', function() + local settingsStub = fakeSettings({ + ['opencc.proxy_url'] = 'https://proxy.example/', + ['opencc.proxy_token'] = 'secret', + }); + local httpStub = fakeHttp(response(200, textutils.serializeJSON({ + sessionId = 'ses_123', + reply = 'pong', + truncated = false, + }))); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, result = ai.askHello(); + + testlib.assertTrue(ok, tostring(result)); + testlib.assertEquals(result.reply, 'pong'); + testlib.assertEquals(result.sessionId, 'ses_123'); + testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_123'); + testlib.assertEquals(settingsStub.saveCount(), 1); + + testlib.assertEquals(#httpStub.calls, 1); + local call = httpStub.calls[1]; + testlib.assertEquals(call.url, 'https://proxy.example/ask'); + testlib.assertEquals(call.headers['Authorization'], 'Bearer secret'); + testlib.assertEquals(call.headers['Content-Type'], 'application/json'); + + local request = textutils.unserializeJSON(call.body); + testlib.assertEquals(request.prompt, 'reply with exactly: pong'); + testlib.assertEquals(request.sessionId, nil); +end); + +testlib.test('askHello includes existing session id', function() + local settingsStub = fakeSettings({ + ['opencc.proxy_url'] = 'https://proxy.example', + ['opencc.proxy_token'] = 'secret', + ['opencc.session_id'] = 'ses_old', + }); + local httpStub = fakeHttp(response(200, textutils.serializeJSON({ + sessionId = 'ses_old', + reply = 'pong', + }))); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok = ai.askHello(); + + testlib.assertTrue(ok); + local request = textutils.unserializeJSON(httpStub.calls[1].body); + testlib.assertEquals(request.sessionId, 'ses_old'); +end); + +testlib.test('askHello rejects missing proxy url', function() + local settingsStub = fakeSettings({ ['opencc.proxy_token'] = 'secret' }); + local httpStub = fakeHttp(response(200, '{}')); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'opencc.proxy_url', 1, true)); + testlib.assertEquals(#httpStub.calls, 0); +end); + +testlib.test('askHello rejects missing proxy token', function() + local settingsStub = fakeSettings({ ['opencc.proxy_url'] = 'https://proxy.example' }); + local httpStub = fakeHttp(response(200, '{}')); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'opencc.proxy_token', 1, true)); + testlib.assertEquals(#httpStub.calls, 0); +end); + +testlib.test('askHello maps proxy unreachable', function() + local settingsStub = fakeSettings({ + ['opencc.proxy_url'] = 'https://proxy.example', + ['opencc.proxy_token'] = 'secret', + }); + local httpStub = fakeHttp(nil); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'proxy injoignable', 1, true)); +end); + +testlib.test('askHello maps 401 response', function() + local settingsStub = fakeSettings({ + ['opencc.proxy_url'] = 'https://proxy.example', + ['opencc.proxy_token'] = 'secret', + }); + local httpStub = fakeHttp(response(401, textutils.serializeJSON({ error = 'unauthorized' }))); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertEquals(err, 'token invalide'); +end); + +testlib.test('clearSession unsets persisted session id', function() + local settingsStub = fakeSettings({ ['opencc.session_id'] = 'ses_old' }); + local ai = createAiHelloWorld({ http = fakeHttp(nil), settings = settingsStub }); + + ai.clearSession(); + + testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); + testlib.assertEquals(settingsStub.saveCount(), 1); +end); + +testlib.run();