Skip to content

Commit 91c7383

Browse files
feat(aiproxy): api-key header (#32)
Handle error from OpenAI without locking the conversation Support Api-Key header that Azure OpenAI use ( use SPIN_VARIABLE_OPENAI_API_KEY_METHOD environment variable to decide which method to use for passing the api key ) Allow refunding all tokens if there is no usage Lock emscripten version. After emscripten 3.1.74 the standard libraries are built with non-mvp features. The NEAR wasm spec is at mvp, and so the build was broken. Enhance test suites to support the different Api-Key passing methods
1 parent b8a119e commit 91c7383

File tree

14 files changed

+240
-63
lines changed

14 files changed

+240
-63
lines changed

.devcontainer/install-dependencies.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ echo 'export PATH="$(pwd)/wabt-1.0.35/bin:$PATH"' >> ~/.bashrc
3434
# Install Emscripten
3535
git clone https://github.com/emscripten-core/emsdk.git
3636
cd emsdk
37+
git checkout 3.1.74
3738
./emsdk install latest
3839
./emsdk activate latest
3940
cd ..

examples/aiproxy/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ target
22
.spin
33
dist
44
web4.js
5+
.env

examples/aiproxy/.test.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
SPIN_VARIABLE_OPENAI_API_KEY=abcd
2+
SPIN_VARIABLE_OPENAI_API_KEY_METHOD=authorization # or "api-key"
23
SPIN_VARIABLE_REFUND_SIGNING_KEY=48QM3KLHFY22hDNnDx6zvgakY1dy66Jsv4dtTT6mt131DtjvPrQn7zyr3CVb1ZKPuVLbmvjQSK9o5vuEvMyiLR5Y
34
SPIN_VARIABLE_FT_CONTRACT_ID=aitoken.test.near
45
SPIN_VARIABLE_OPENAI_COMPLETIONS_ENDPOINT=http://127.0.0.1:3001/v1/chat/completions
5-
SPIN_VARIABLE_RPC_URL=http://localhost:14500
6+
SPIN_VARIABLE_RPC_URL=http://localhost:14500

examples/aiproxy/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ This folder contains a [Spin](https://www.fermyon.com/spin) application, based o
44

55
There is a simple example of a web client in the [web](./web/) folder.
66

7-
The application will keep track of of token usage per conversation in the built-in key-value storage of Spin. The initial balance for a conversation is retrieved from the Fungible Token smart contract.
7+
The application will keep track of token usage per conversation in the built-in key-value storage of Spin. The initial balance for a conversation is retrieved from the Fungible Token smart contract.
88

99
To launch the application, make sure to have the Spin SDK installed.
1010

1111
You also need to set some environment variables:
1212

1313
- `SPIN_VARIABLE_OPENAI_API_KEY` your OpenAI API key.
14+
- `SPIN_VARIABLE_OPENAI_API_KEY_METHOD` specifies the method to provide the API key. Use `authorization` for OpenAI (default) and `api-key` for Azure OpenAI.
1415
- `SPIN_VARIABLE_REFUND_SIGNING_KEY` an ed21159 secret key that will be used to sign refund requests. You can run the [create-refund-signing-keypair.js](./create-refund-signing-keypair.js) script to create the keypair. Run it using the command `$(node create-refund-signing-keypair.js)` and it will set the environment variable for you.
1516
- `SPIN_VARIABLE_FT_CONTRACT_ID` the NEAR contract account id. e.g `aitoken.test.near`
1617
- `SPIN_VARIABLE_OPENAI_COMPLETIONS_ENDPOINT` OpenAI API completions endpoint. E.g. https://api.openai.com/v1/chat/completions

examples/aiproxy/openai-proxy/src/lib.rs

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,19 @@ async fn handle_request(request: Request, response_out: ResponseOutparam) {
247247

248248
match proxy_openai(messages).await {
249249
Ok(incoming_response) => {
250+
if incoming_response.status() != 200 {
251+
conversation_balance.locked_for_ongoing_request = false;
252+
conversation_balance_store
253+
.set_json(conversation_id, &conversation_balance)
254+
.unwrap();
255+
let response_data = incoming_response.into_body().await.unwrap();
256+
let response_string = String::from_utf8(response_data).unwrap();
257+
eprintln!(
258+
"error in response from OpenAI endpoint: {:?}",
259+
response_string
260+
);
261+
return server_error(response_out);
262+
}
250263
let mut incoming_response_body = incoming_response.take_body_stream();
251264
let outgoing_response = OutgoingResponse::new(headers);
252265
let mut outgoing_response_body = outgoing_response.take_body();
@@ -458,18 +471,24 @@ async fn proxy_openai(messages: Value) -> anyhow::Result<IncomingResponse> {
458471
});
459472

460473
let openai_completions_endpoint = variables::get("openai_completions_endpoint")?;
461-
let outgoing_request = Request::builder()
474+
let api_key = variables::get("openai_api_key").unwrap();
475+
let api_key_method =
476+
variables::get("openai_api_key_method").unwrap_or_else(|_| "authorization".to_string());
477+
478+
let mut openai_request_builder = Request::builder();
479+
openai_request_builder
462480
.method(Method::Post)
463481
.uri(openai_completions_endpoint)
464-
.header(
465-
"Authorization",
466-
format!("Bearer {}", variables::get("openai_api_key").unwrap()),
467-
)
468-
.header("Content-Type", "application/json")
469-
.body(request_body.to_string())
470-
.build();
471-
472-
let response = match http::send::<_, IncomingResponse>(outgoing_request).await {
482+
.header("Content-Type", "application/json");
483+
484+
let openai_request = match api_key_method.as_str() {
485+
"api-key" => openai_request_builder.header("Api-Key", api_key),
486+
_ => openai_request_builder.header("Authorization", format!("Bearer {}", api_key)),
487+
}
488+
.body(request_body.to_string())
489+
.build();
490+
491+
let response = match http::send::<_, IncomingResponse>(openai_request).await {
473492
Ok(resp) => resp,
474493
Err(e) => {
475494
eprintln!("Error sending request to OpenAI: {e}");

examples/aiproxy/playwright-tests/aiproxy.spec.js

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,64 @@
1-
21
import { test, expect } from '@playwright/test';
2+
import { spawn } from 'child_process';
3+
import { fileURLToPath } from 'url';
4+
import path from 'path';
5+
import http from 'http';
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const mockServerPath = path.resolve(__dirname, 'openaimockserver.js');
10+
11+
let mockServerProcess;
12+
13+
async function startMockServer(apiKeyMethod, apikey = 'abcd') {
14+
if (mockServerProcess) {
15+
await new Promise((resolve) => {
16+
mockServerProcess.on('close', resolve);
17+
mockServerProcess.kill();
18+
});
19+
}
20+
21+
mockServerProcess = spawn('node', [mockServerPath], {
22+
env: {
23+
...process.env,
24+
SPIN_VARIABLE_OPENAI_API_KEY: apikey,
25+
SPIN_VARIABLE_OPENAI_API_KEY_METHOD: apiKeyMethod,
26+
},
27+
});
28+
29+
mockServerProcess.stdout.on('data', (data) => {
30+
console.log(`stdout: ${data}`);
31+
});
32+
33+
mockServerProcess.stderr.on('data', (data) => {
34+
console.error(`stderr: ${data}`);
35+
});
36+
37+
// Wait for the server to start and respond on port 3001
38+
await new Promise((resolve, reject) => {
39+
const interval = setInterval(() => {
40+
http.get('http://127.0.0.1:3001', (res) => {
41+
if (res.statusCode === 200) {
42+
clearInterval(interval);
43+
resolve();
44+
}
45+
}).on('error', () => {
46+
// Ignore errors, keep trying
47+
});
48+
}, 500);
49+
});
50+
}
51+
52+
test.afterEach(async () => {
53+
if (mockServerProcess) {
54+
await new Promise((resolve) => {
55+
mockServerProcess.on('close', resolve);
56+
mockServerProcess.kill();
57+
});
58+
}
59+
});
360

4-
test('ask question', async ({ page }) => {
61+
async function testConversation({page, expectedRefundAmount = "127999973", expectedOpenAIResponse = "Hello! How can I assist you today?"}) {
562
const { functionAccessKeyPair, publicKey, accountId, contractId } = await fetch('http://localhost:14501').then(r => r.json());
663

764
await page.goto('/');
@@ -20,10 +77,28 @@ test('ask question', async ({ page }) => {
2077
questionArea.fill("Hello!");
2178
await page.waitForTimeout(500);
2279
await page.getByRole('button', { name: 'Ask AI' }).click();
23-
await expect(await page.getByText("Hello! How can I assist you today?")).toBeVisible();
80+
await expect(await page.getByText(expectedOpenAIResponse)).toBeVisible();
2481

2582
await page.waitForTimeout(500);
2683
await page.locator("#refundButton").click();
2784

28-
await expect(await page.locator("#refund_message")).toContainText(`EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"${contractId}","new_owner_id":"${accountId}","amount":"127999973"}]}\nrefunded 127999973 to ${accountId}`);
85+
await expect(await page.locator("#refund_message")).toContainText(`EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"${contractId}","new_owner_id":"${accountId}","amount":"${expectedRefundAmount}"}]}\nrefunded ${expectedRefundAmount} to ${accountId}`, {timeout: 10_000});
86+
}
87+
88+
test('start conversation, ask question and refund (using OpenAI authorization header)', async ({ page }) => {
89+
await startMockServer('authorization');
90+
await testConversation({page});
91+
});
92+
93+
94+
95+
test('start conversation, ask question and refund (using Azure OpenAI Api-Key header)', async ({ page }) => {
96+
await startMockServer('api-key');
97+
await testConversation({page});
98+
});
99+
100+
test('start conversation, ask question, where openai API fails, and refund (using wrong OpenAI API key)', async ({ page }) => {
101+
await startMockServer('api-key', "1234ffff");
102+
103+
await testConversation({page, expectedRefundAmount: "128000000", expectedOpenAIResponse: "Failed to fetch from proxy: Internal Server Error"});
29104
});

examples/aiproxy/playwright-tests/near_rpc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ await aiuser.call(aiTokenAccount.accountId, 'storage_deposit', {
3737

3838
await aiTokenAccount.call(aiTokenAccount.accountId, 'ft_transfer', {
3939
receiver_id: aiuser.accountId,
40-
amount: 128_000_000n.toString(),
40+
amount: (100n*128_000_000n).toString(),
4141
}, {
4242
attachedDeposit: 1n.toString()
4343
});

examples/aiproxy/playwright-tests/openaimockserver.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createServer } from "http";
22
import { Readable } from "stream";
33

44
const PORT = 3001;
5+
const API_KEY = process.env.SPIN_VARIABLE_OPENAI_API_KEY;
6+
const API_KEY_METHOD = process.env.SPIN_VARIABLE_API_KEY_METHOD || "authorization";
57

68
const server = createServer((req, res) => {
79
if (req.method === "POST" && req.url.startsWith("/v1/chat/completions")) {
@@ -12,6 +14,21 @@ const server = createServer((req, res) => {
1214
});
1315

1416
req.on("end", () => {
17+
let apiKeyValid = false;
18+
19+
if (API_KEY_METHOD === "api-key") {
20+
apiKeyValid = req.headers["api-key"] === API_KEY;
21+
} else {
22+
const authHeader = req.headers["authorization"];
23+
apiKeyValid = authHeader && authHeader === `Bearer ${API_KEY}`;
24+
}
25+
26+
if (!apiKeyValid) {
27+
res.writeHead(401, { "Content-Type": "application/json" });
28+
res.end(JSON.stringify({ error: "Unauthorized" }));
29+
return;
30+
}
31+
1532
const responseChunks = [
1633
JSON.stringify({
1734
choices: [

examples/aiproxy/playwright.config.js

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
11
import { defineConfig, devices } from '@playwright/test';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import dotenv from 'dotenv';
5+
import { fileURLToPath } from 'url';
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
10+
const envFilePath = path.resolve(__dirname, '.test.env');
11+
const envConfig = dotenv.parse(fs.readFileSync(envFilePath));
12+
13+
for (const k in envConfig) {
14+
process.env[k] = envConfig[k];
15+
}
216

317
/**
418
* @see https://playwright.dev/docs/test-configuration
@@ -64,25 +78,22 @@ export default defineConfig({
6478
],
6579

6680
/* Run your local dev server before starting the tests */
67-
webServer: [{
68-
command: 'static-web-server -p 8080 -d web',
69-
url: 'http://127.0.0.1:8080',
70-
reuseExistingServer: !process.env.CI,
71-
},
72-
{
73-
command: "export $(grep -v '^#' .test.env | xargs) && spin build && spin up",
74-
url: 'http://127.0.0.1:3000',
75-
reuseExistingServer: !process.env.CI,
76-
},
77-
{
78-
command: "export $(grep -v '^#' .test.env | xargs) && node playwright-tests/openaimockserver.js",
79-
url: 'http://127.0.0.1:3001',
80-
reuseExistingServer: !process.env.CI,
81-
},
82-
{
83-
command: "export $(grep -v '^#' .test.env | xargs) && node playwright-tests/near_rpc.js",
84-
url: 'http://127.0.0.1:14501',
85-
reuseExistingServer: !process.env.CI,
86-
}],
81+
webServer: [
82+
{
83+
command: "spin build && spin up",
84+
url: 'http://127.0.0.1:3000',
85+
reuseExistingServer: !process.env.CI,
86+
},
87+
{
88+
command: "node playwright-tests/near_rpc.js",
89+
url: 'http://127.0.0.1:14501',
90+
reuseExistingServer: !process.env.CI,
91+
},
92+
{
93+
command: "npx http-server ./web -p 8080",
94+
url: 'http://127.0.0.1:8080',
95+
reuseExistingServer: !process.env.CI,
96+
}
97+
],
8798
});
8899

examples/aiproxy/spin.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ workdir = "openai-proxy"
2121
watch = ["src/**/*.rs", "Cargo.toml"]
2222

2323
[variables]
24+
openai_api_key_method = { default = "authorization" }
2425
openai_api_key = { required = true }
2526
refund_signing_key = { required = true }
2627
openai_completions_endpoint = { required = true }
2728
ft_contract_id = { required = true }
2829
rpc_url = {required = true }
2930

3031
[component.openai-proxy.variables]
32+
openai_api_key_method = "{{ openai_api_key_method }}"
3133
openai_api_key = "{{ openai_api_key }}"
3234
refund_signing_key = "{{ refund_signing_key }}"
3335
openai_completions_endpoint = "{{ openai_completions_endpoint }}"

0 commit comments

Comments
 (0)