Compare commits

...
5 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.7 e26226f370 feat(proxy): Tool-Use durchreichen — eigene Adapter-Files ueberschreiben npm-Version
Der claude-max-api-proxy ignoriert das OpenAI-tools-Feld komplett:
openai-to-cli.js wandelt nur messages in einen String, manager.js
spawnt 'claude --print' ohne Tools. Claude Code nutzt dann ihre
internen Tools (Bash, etc.) — bei 'Timer in 2min' macht sie ein
'sleep 120' intern und meldet 'erledigt' ohne dass wir je einen
trigger_timer-Call sehen.

Fix: zwei eigene Adapter-Files unter proxy-patches/ die zur
Container-Startzeit ueber die npm-Version kopiert werden:

  openai-to-cli.js:
    - tools-Feld wird als <system>-Block mit Tool-Schemas + klarer
      Anweisung "Antworte <tool_call name=...>{json}</tool_call>"
      in den Prompt injiziert
    - role=tool messages werden als <tool_result>-Blocks eingewoben
      → Claude sieht den ganzen Tool-Use-Loop
    - assistant tool_calls werden als <tool_call>-Bloecke
      re-serialisiert, damit History-Roundtrips funktionieren
    - Multimodal-content (Array von text-Parts) unveraendert
      unterstuetzt (Original-sed-Patch eingebaut)

  cli-to-openai.js:
    - parsed <tool_call name="X">{json}</tool_call> aus result.result
    - liefert OpenAI-konforme tool_calls + finish_reason=tool_calls
    - Pre-Tool-Text bleibt im content erhalten
    - normalizeModelName null-safe (Original-sed-Patch eingebaut)

docker-compose.yml: zwei sed-Patches die jetzt in den Files leben
sind raus, dafuer ein /proxy-patches:ro-Mount + zwei cp-Kommandos.

Smoke-Tests mit Node lokal alle gruen (single + multi tool_calls,
mit/ohne Pre-Text, History-Replay mit tool_result).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:42:05 +02:00
duffyduckandClaude Opus 4.7 0d13118f7e debug(brain): Proxy-Response loggen — finish_reason + raw-msg + tool_calls-Anzahl
Diagnose-Log um Trigger-Hang zu klaeren: warum legt ARIA keinen Timer
an, obwohl trigger_timer als Tool definiert ist? Wir loggen jetzt nach
jedem Proxy-Call:
  - finish_reason
  - alle Keys aus der message
  - tool_calls-Anzahl + content-Laenge
  - die rohe message (truncated 1500 chars)

So sehen wir ob der Proxy tool_calls leer liefert (Proxy schluckt
tools-Feld?), ob Claude ignoriert (Anthropic-Native-Format statt
OpenAI?), oder ob unser Dispatch falsch parsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:34:12 +02:00
duffyduck b1796520b8 release: bump version to 0.1.2.7 2026-05-12 01:23:18 +02:00
duffyduckandClaude Opus 4.7 0ff44d99c4 docs: README + issue — Aktuelle-Zeit-Block + in_seconds dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:22:14 +02:00
duffyduckandClaude Opus 4.7 8c74b3fed8 fix(brain): Timer in 2min funktioniert wieder — Zeit im Prompt + in_seconds-Param
ARIA wusste nicht wieviel Uhr es ist (kein Bash, kein Time-Tool, kein
Timestamp im System-Prompt) und konnte fires_at als ISO-UTC schlicht
nicht ausrechnen. Zwei Fixes:

1. prompts.py: build_time_section() injiziert UTC + lokale Zeit
   (Europa/Berlin Sommer/Winter-Heuristik) als '## Aktuelle Zeit'-Block
   oben in den System-Prompt. Hilft auch beim Einordnen von
   Watcher-Conditions wie hour_of_day == 8.

2. agent.py trigger_timer-Tool: neuer Parameter `in_seconds` als
   Alternative zu fires_at. Bei relativen Angaben ('in 2 Minuten')
   rechnet jetzt der Server den absoluten Timestamp aus — keine
   Rechnerei in der LLM noetig. fires_at bleibt fuer feste Termine.
   required nur noch name + message.

Diagnostic-API (/triggers/timer) bleibt absolute-only, da der Browser
selbst datetime hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:21:23 +02:00
10 changed files with 384 additions and 17 deletions
+1 -1
View File
@@ -315,7 +315,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs - **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche + Filter, Edit/Add/Delete, Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz). Info-Buttons () ueberall mit Modal-Erklaerung. - **Gehirn**: Memory-Browser (Vector-DB), Suche + Filter, Edit/Add/Delete, Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz). Info-Buttons () ueberall mit Modal-Erklaerung.
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute - **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). - **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete. - **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup - **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10206 versionCode 10207
versionName "0.1.2.6" versionName "0.1.2.7"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.2.6", "version": "0.1.2.7",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+31 -9
View File
@@ -97,26 +97,35 @@ META_TOOLS = [
"function": { "function": {
"name": "trigger_timer", "name": "trigger_timer",
"description": ( "description": (
"Lege einen Timer-Trigger an — feuert EINMALIG zum angegebenen Zeitpunkt " "Lege einen Timer-Trigger an — feuert EINMALIG und ruft dich dann selbst auf "
"und ruft dich selbst auf (Push-Nachricht an Stefan). " "(Push-Nachricht an Stefan). Use-Case: 'erinnere mich in 10min', "
"Use-Case: 'erinnere mich in 10min', 'sag mir um 14:30 Bescheid'." "'sag mir um 14:30 Bescheid'. Genau EINES von `in_seconds` ODER `fires_at` "
"muss gesetzt sein."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"name": {"type": "string", "description": "kurzer kebab-case-Name, a-z 0-9 - _"}, "name": {"type": "string", "description": "kurzer kebab-case-Name, a-z 0-9 - _"},
"in_seconds": {
"type": "integer",
"description": (
"Relativ ab jetzt in Sekunden. Bevorzugt bei Angaben wie "
"'in 2 Minuten' (=120), 'in 1 Stunde' (=3600). "
"Server berechnet daraus den absoluten Feuer-Zeitpunkt."
),
},
"fires_at": { "fires_at": {
"type": "string", "type": "string",
"description": ( "description": (
"Absoluter ISO-Timestamp UTC, z.B. '2026-05-12T14:30:00Z'. " "Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. "
"Berechne aus relativer Angabe ('in 10min') selbst — die " "'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im "
"aktuelle Zeit findest du im System-Prompt nicht, also nutze " "System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben "
"Bash: `date -u -d '+10 minutes' --iso-8601=seconds`." "lieber `in_seconds` nutzen."
), ),
}, },
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"}, "message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
}, },
"required": ["name", "fires_at", "message"], "required": ["name", "message"],
}, },
}, },
}, },
@@ -389,9 +398,22 @@ class Agent:
out += f"\nstderr:\n{err}" out += f"\nstderr:\n{err}"
return out return out
if name == "trigger_timer": if name == "trigger_timer":
fires_at_iso = arguments.get("fires_at")
in_seconds = arguments.get("in_seconds")
if not fires_at_iso and in_seconds is not None:
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
try:
secs = int(in_seconds)
except (TypeError, ValueError):
return "FEHLER: in_seconds muss eine ganze Zahl sein."
if secs < 1:
return "FEHLER: in_seconds muss >= 1 sein."
fires_at_iso = (_dt.now(_tz.utc) + _td(seconds=secs)).isoformat(timespec="seconds")
if not fires_at_iso:
return "FEHLER: entweder `in_seconds` ODER `fires_at` muss gesetzt sein."
t = triggers_mod.create_timer( t = triggers_mod.create_timer(
name=arguments["name"], name=arguments["name"],
fires_at_iso=arguments["fires_at"], fires_at_iso=fires_at_iso,
message=arguments["message"], message=arguments["message"],
author="aria", author="aria",
) )
+25 -1
View File
@@ -15,10 +15,34 @@ mit dem Conversation-Loop in spaeteren Phasen.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone, timedelta
from typing import List from typing import List
from memory import MemoryPoint from memory import MemoryPoint
def build_time_section() -> str:
"""Aktueller Zeitstempel — damit ARIA Timer korrekt anlegen kann
und Watcher-Conditions mit hour_of_day etc. einordenbar bleiben."""
now_utc = datetime.now(timezone.utc)
# Europa/Berlin: Sommerzeit CEST = UTC+2, Winterzeit CET = UTC+1.
# Wir nehmen den simplen Fall (kein zoneinfo-Import noetig im Brain-Image):
# Stefans VM laeuft auf UTC, die Bridge in der Wohnung — Anzeige reicht.
local_offset_h = 2 if 3 <= now_utc.month <= 10 else 1
local = now_utc + timedelta(hours=local_offset_h)
lines = [
"## Aktuelle Zeit",
f"- UTC: {now_utc.isoformat(timespec='seconds')}",
f"- Lokal (Europa/Berlin, UTC+{local_offset_h}): "
f"{local.strftime('%Y-%m-%d %H:%M:%S')} ({local.strftime('%A')})",
"",
"Nutze das fuer Trigger-Timestamps und um Watcher-Conditions wie "
"`hour_of_day == 8` einzuordnen. Fuer relative Angaben "
"('in 10min', 'in 2 Stunden') nutze beim `trigger_timer` den "
"`in_seconds`-Parameter — Server rechnet dann selbst.",
]
return "\n".join(lines)
TYPE_HEADINGS = { TYPE_HEADINGS = {
"identity": "## Wer du bist", "identity": "## Wer du bist",
"rule": "## Sicherheitsregeln & Prinzipien", "rule": "## Sicherheitsregeln & Prinzipien",
@@ -177,7 +201,7 @@ def build_system_prompt(
condition_funcs: List[dict] | None = None, condition_funcs: List[dict] | None = None,
) -> str: ) -> str:
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers.""" """Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
parts = [build_hot_memory_section(pinned)] parts = [build_hot_memory_section(pinned), "", build_time_section()]
if skills: if skills:
parts.append("") parts.append("")
parts.append(build_skills_section(skills)) parts.append(build_skills_section(skills))
+14
View File
@@ -111,6 +111,20 @@ class ProxyClient:
msg = choices[0].get("message") or {} msg = choices[0].get("message") or {}
finish_reason = choices[0].get("finish_reason", "") finish_reason = choices[0].get("finish_reason", "")
# Diagnose: was hat der Proxy zurueckgegeben?
# Wir loggen die rohe message + finish_reason damit wir sehen ob
# tool_calls da sind, leer oder schlicht weggeschnitten werden.
logger.info("Proxy ← finish=%s keys=%s tool_calls=%d content_len=%d",
finish_reason,
sorted(msg.keys()),
len(msg.get("tool_calls") or []),
len(msg.get("content") or "") if isinstance(msg.get("content"), str)
else sum(len(p.get("text", "")) for p in (msg.get("content") or []) if isinstance(p, dict)))
try:
logger.info("Proxy ← raw-msg=%s", json.dumps(msg)[:1500])
except Exception:
logger.info("Proxy ← raw-msg(non-serial)=%s", str(msg)[:1500])
content = msg.get("content") or "" content = msg.get("content") or ""
if isinstance(content, list): if isinstance(content, list):
content = "".join( content = "".join(
+3 -3
View File
@@ -11,15 +11,15 @@ services:
npm install -g @anthropic-ai/claude-code claude-max-api-proxy && npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) && DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js && sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/if (model\.includes/if ((model||\"claude-sonnet-4\").includes/g' $$DIST/adapter/cli-to-openai.js &&
sed -i '1i\\function _t(c){return typeof c===\"string\"?c:Array.isArray(c)?c.filter(function(b){return b.type===\"text\"}).map(function(b){return b.text||\"\"}).join(\"\"):String(c)}' $$DIST/adapter/openai-to-cli.js &&
sed -i 's/msg\\.content/_t(msg.content)/g' $$DIST/adapter/openai-to-cli.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js && sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
claude-max-api" claude-max-api"
volumes: volumes:
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json) - ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA) - ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App) - aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
environment: environment:
- HOST=0.0.0.0 - HOST=0.0.0.0
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash) - SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
+2
View File
@@ -55,6 +55,7 @@ Wichtige Mechanismen:
### Bugs / Fixes ### Bugs / Fixes
- [x] **Timer "in 2 Minuten" wird wieder angelegt**: ARIA hatte keine Moeglichkeit die aktuelle Zeit zu kennen — kein Bash-Tool, kein Time-Tool, kein Timestamp im System-Prompt. Die Tool-Beschreibung von `trigger_timer` empfahl sogar `date -u -d '+10 minutes'` via Bash, aber Bash gab's nicht. Folge: LLM liess den Tool-Call entweder weg oder riet einen Cutoff-Zeitstempel (Vergangenheit) → Background-Loop feuerte beim naechsten 30s-Tick sofort statt in 2min. Fix: (1) `build_time_section()` in `prompts.py` injiziert UTC + lokale Europa/Berlin-Zeit als `## Aktuelle Zeit`-Block oben im System-Prompt. (2) `trigger_timer` akzeptiert jetzt `in_seconds` als Alternative zu `fires_at` — Server rechnet den absoluten Timestamp, ARIA muss nicht ISO-rechnen
- [x] **"ARIA denkt..." haengt nach Brain-Antwort** (App + Diagnostic): `send_to_core` schickte `thinking` direkt via `_send_to_rvs`, hat aber `_last_activity_state` nicht gepflegt — der spaetere `_emit_activity("idle")` wurde dedupliziert und verschluckt. Fix: durchgehend `_emit_activity` fuer beide Zustaende - [x] **"ARIA denkt..." haengt nach Brain-Antwort** (App + Diagnostic): `send_to_core` schickte `thinking` direkt via `_send_to_rvs`, hat aber `_last_activity_state` nicht gepflegt — der spaetere `_emit_activity("idle")` wurde dedupliziert und verschluckt. Fix: durchgehend `_emit_activity` fuer beide Zustaende
- [x] **Such-Scroll in App-Chat springt jetzt zur Treffer-Bubble**: `scrollToIndex` wurde zu frueh gerufen + `viewPosition: 0.4` schoss vorbei. Fix: `requestAnimationFrame` + `viewPosition: 0.5` + `onScrollToIndexFailed`-Fallback mit averageItemLength-Schaetzung + 250ms-Retry - [x] **Such-Scroll in App-Chat springt jetzt zur Treffer-Bubble**: `scrollToIndex` wurde zu frueh gerufen + `viewPosition: 0.4` schoss vorbei. Fix: `requestAnimationFrame` + `viewPosition: 0.5` + `onScrollToIndexFailed`-Fallback mit averageItemLength-Schaetzung + 250ms-Retry
- [x] **STT-Bubble bekommt den Text jetzt sofort** (nicht erst mit ARIAs Antwort): `_process_app_audio` rief erst `send_to_core` (blockt synchron) und DANN STT-Broadcast. Fix: Reihenfolge getauscht — STT raus, dann Core-Call - [x] **STT-Bubble bekommt den Text jetzt sofort** (nicht erst mit ARIAs Antwort): `_process_app_audio` rief erst `send_to_core` (blockt synchron) und DANN STT-Broadcast. Fix: Reihenfolge getauscht — STT raus, dann Core-Call
@@ -275,6 +276,7 @@ Skills mit Tool-Use.
- [x] **Activity-Persistenz**: `/shared/state/activity.json` traegt User-Message-Zeitstempel, damit `last_user_message_ago_sec` als Variable verfuegbar ist - [x] **Activity-Persistenz**: `/shared/state/activity.json` traegt User-Message-Zeitstempel, damit `last_user_message_ago_sec` als Variable verfuegbar ist
- [x] **`trigger_cancel`** + **`trigger_list`** als Tools — ARIA kann eigene Trigger verwalten - [x] **`trigger_cancel`** + **`trigger_list`** als Tools — ARIA kann eigene Trigger verwalten
- [x] **Triggers-Block im System-Prompt**: aktive Trigger + verfuegbare Variablen + Funktionen werden bei jedem Chat-Turn injiziert, dazu Hinweis dass GPS-Watcher `request_location_tracking` mit-aufrufen sollen - [x] **Triggers-Block im System-Prompt**: aktive Trigger + verfuegbare Variablen + Funktionen werden bei jedem Chat-Turn injiziert, dazu Hinweis dass GPS-Watcher `request_location_tracking` mit-aufrufen sollen
- [x] **Aktuelle-Zeit-Block im System-Prompt**: UTC + lokale Europa/Berlin-Zeit (Sommer/Winter-Heuristik) wird bei jedem Chat-Turn oben mit-injiziert, damit Timer-fires_at und Watcher mit `hour_of_day` ueberhaupt sinnvoll sind. `trigger_timer` akzeptiert zusaetzlich `in_seconds` (Server rechnet) — ARIA muss bei relativen Angaben ('in 2 Minuten') nicht selbst ISO-rechnen
### Diagnostic / App Features (drumherum) ### Diagnostic / App Features (drumherum)
+146
View File
@@ -0,0 +1,146 @@
/**
* ARIA-patched cli-to-openai adapter.
*
* Erweitert die npm-Version von claude-max-api-proxy:
* - normalizeModelName ist null-safe (Original-Patch der vorher per sed lief).
* - Parser fuer <tool_call name="X">{json}</tool_call>-Bloecke im Result-Text:
* Wenn welche gefunden werden, wandert das in `message.tool_calls`
* (OpenAI-Format) und finish_reason=tool_calls. Der restliche Text
* (alles ausserhalb der Bloecke) wird verworfen, weil das interner
* Tool-Use-Schritt war, nicht User-facing.
*
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
* (siehe docker-compose.yml proxy-Block).
*/
import { randomUUID } from "crypto";
export function extractTextContent(message) {
return message.message.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
}
export function cliToOpenaiChunk(message, requestId, isFirst = false) {
const text = extractTextContent(message);
return {
id: `chatcmpl-${requestId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: normalizeModelName(message.message.model),
choices: [
{
index: 0,
delta: {
role: isFirst ? "assistant" : undefined,
content: text,
},
finish_reason: message.message.stop_reason ? "stop" : null,
},
],
};
}
export function createDoneChunk(requestId, model) {
return {
id: `chatcmpl-${requestId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: normalizeModelName(model),
choices: [
{
index: 0,
delta: {},
finish_reason: "stop",
},
],
};
}
/**
* Sucht im Result-Text alle <tool_call name="...">{json}</tool_call>
* Bloecke. Gibt [{id, name, arguments(json-string)}, restText] zurueck.
*
* Defensiv:
* - "name"-Attribut sowohl in Doppel- als auch Einzelhochkommata
* - Whitespace beim JSON tolerant
* - Bei JSON-Parse-Fehler: das Argument wird als _raw weitergereicht
* (unser Brain-Side-Parser kennt das)
*/
function _parseToolCalls(text) {
if (!text || typeof text !== "string") return { tool_calls: [], rest: text || "" };
const re = /<tool_call\s+name=["']([^"']+)["']\s*>([\s\S]*?)<\/tool_call>/gi;
const tcs = [];
let lastIndex = 0;
const restParts = [];
let m;
while ((m = re.exec(text)) !== null) {
restParts.push(text.slice(lastIndex, m.index));
const name = m[1];
let argsBody = (m[2] || "").trim();
// Fences entfernen falls Claude welche eingebaut hat
argsBody = argsBody.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
if (!argsBody) argsBody = "{}";
// Validieren — aber in OpenAI-Format ist arguments immer ein STRING
try {
JSON.parse(argsBody);
} catch (_) {
// Behalten als Roh-String — Brain-Side toleriert das via {_raw:...}
}
tcs.push({
id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`,
type: "function",
function: { name, arguments: argsBody },
});
lastIndex = re.lastIndex;
}
restParts.push(text.slice(lastIndex));
return { tool_calls: tcs, rest: restParts.join("").trim() };
}
export function cliResultToOpenai(result, requestId) {
const modelName = result.modelUsage
? Object.keys(result.modelUsage)[0]
: "claude-sonnet-4";
const rawText = result.result || "";
const { tool_calls, rest } = _parseToolCalls(rawText);
const message = { role: "assistant" };
let finishReason = "stop";
if (tool_calls.length > 0) {
message.tool_calls = tool_calls;
// Wenn Claude neben den Tool-Calls noch Text geschrieben hat, behalten
// wir den im content — Brain-Seite kann ihn als Pre-Tool-Plaintext sehen.
// Wenn nur Tool-Calls da waren (rest leer), content explizit null.
message.content = rest || null;
finishReason = "tool_calls";
} else {
message.content = rawText;
}
return {
id: `chatcmpl-${requestId}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: normalizeModelName(modelName),
choices: [
{ index: 0, message, finish_reason: finishReason },
],
usage: {
prompt_tokens: result.usage?.input_tokens || 0,
completion_tokens: result.usage?.output_tokens || 0,
total_tokens:
(result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0),
},
};
}
function normalizeModelName(model) {
const m = model || "claude-sonnet-4";
if (m.includes("opus")) return "claude-opus-4";
if (m.includes("sonnet")) return "claude-sonnet-4";
if (m.includes("haiku")) return "claude-haiku-4";
return m;
}
+159
View File
@@ -0,0 +1,159 @@
/**
* ARIA-patched openai-to-cli adapter.
*
* Erweitert die npm-Version von claude-max-api-proxy:
* - Multimodal-Content (Array von text-Parts) wird zu String reduziert.
* - Wenn die Anfrage ein `tools`-Feld enthaelt: die Tool-Definitionen
* werden in den Prompt als <system>-Block injiziert, mit klarer
* Anweisung das <tool_call name="...">{...}</tool_call> Format
* zu verwenden statt freiem Text.
* - Wenn Messages role=tool enthalten: deren Inhalt wird als
* <tool_result tool_call_id="...">…</tool_result> ins Prompt-Fragment
* eingewoben damit Claude den Loop-Step bekommt.
*
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
* (siehe docker-compose.yml proxy-Block).
*/
const MODEL_MAP = {
"claude-opus-4": "opus",
"claude-sonnet-4": "sonnet",
"claude-haiku-4": "haiku",
"claude-code-cli/claude-opus-4": "opus",
"claude-code-cli/claude-sonnet-4": "sonnet",
"claude-code-cli/claude-haiku-4": "haiku",
"opus": "opus",
"sonnet": "sonnet",
"haiku": "haiku",
};
export function extractModel(model) {
if (MODEL_MAP[model]) return MODEL_MAP[model];
const stripped = (model || "").replace(/^claude-code-cli\//, "");
if (MODEL_MAP[stripped]) return MODEL_MAP[stripped];
return "opus";
}
/** Multimodal: content kann String oder Array von Parts sein. */
function _text(c) {
if (typeof c === "string") return c;
if (Array.isArray(c)) {
return c
.filter((b) => b && b.type === "text")
.map((b) => b.text || "")
.join("");
}
return String(c == null ? "" : c);
}
/**
* Baut den Tool-Use-Block fuer den System-Prompt.
* Anweisung: Claude soll <tool_call name="X">{json args}</tool_call>
* ausgeben statt das Tool intern via Bash zu simulieren.
*/
function _toolsBlock(tools) {
if (!Array.isArray(tools) || tools.length === 0) return "";
const lines = [];
lines.push("# Verfuegbare Tools");
lines.push("");
lines.push(
"Du hast neben deinen eigenen internen Tools (Bash, Read, etc.) auch " +
"diese externen Tools, die im Backend-System angesiedelt sind. " +
"Sie sind die EINZIGE Moeglichkeit Aktionen auszuloesen wie Trigger anlegen, " +
"Skills aufrufen, oder Konfiguration aendern. Simuliere sie NICHT mit Bash/sleep — " +
"rufe sie sauber auf:"
);
lines.push("");
for (const t of tools) {
if (!t || t.type !== "function" || !t.function) continue;
const fn = t.function;
const name = fn.name || "";
const desc = fn.description || "";
const params = fn.parameters || {};
lines.push(`## ${name}`);
if (desc) lines.push(desc);
try {
lines.push("Schema: " + JSON.stringify(params));
} catch (_) {
lines.push("Schema: (nicht serialisierbar)");
}
lines.push("");
}
lines.push("# Tool-Call-Format");
lines.push("");
lines.push(
"Wenn du eines der OBIGEN externen Tools aufrufen willst, antworte " +
"**ausschliesslich** mit einem oder mehreren Bloecken in genau dieser Form, " +
"JEDER fuer sich auf einer eigenen Zeile:"
);
lines.push("");
lines.push('<tool_call name="TOOL_NAME">{"arg1":"value","arg2":123}</tool_call>');
lines.push("");
lines.push(
"Regeln: (1) Innerhalb des Blocks steht NUR gueltiges JSON mit den Argumenten. " +
"(2) Kein Text drumherum. (3) Keine Code-Fences, kein Markdown. " +
"(4) Mehrere Tool-Calls = mehrere Bloecke untereinander. " +
"(5) Nach den Bloecken aufhoeren — der Server fuehrt die Tools aus und " +
"schickt dir die Ergebnisse fuer den naechsten Turn. " +
"(6) Wenn KEIN externes Tool noetig ist, antworte normal als Text fuer den User. " +
"(7) Nutze Bash/sleep NICHT als Ersatz fuer trigger_timer — das ist genau " +
"der Bug den wir damit fixen."
);
return lines.join("\n");
}
/**
* Wandelt OpenAI-messages in einen Single-String-Prompt um.
* - system/user/assistant wie bisher
* - tool-role: als <tool_result tool_call_id="..." name="..."> eingewoben
*/
export function messagesToPrompt(messages, tools) {
const parts = [];
const toolsBlock = _toolsBlock(tools);
if (toolsBlock) {
parts.push(`<system>\n${toolsBlock}\n</system>\n`);
}
for (const msg of messages) {
if (!msg) continue;
switch (msg.role) {
case "system":
parts.push(`<system>\n${_text(msg.content)}\n</system>\n`);
break;
case "user":
parts.push(_text(msg.content));
break;
case "assistant": {
const txt = _text(msg.content);
const tcs = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
const tcParts = tcs.map((tc) => {
const name = tc?.function?.name || tc?.name || "";
let args = tc?.function?.arguments ?? tc?.arguments ?? "{}";
if (typeof args !== "string") {
try { args = JSON.stringify(args); } catch (_) { args = "{}"; }
}
return `<tool_call name="${name}">${args}</tool_call>`;
}).join("\n");
const combined = [txt, tcParts].filter(Boolean).join("\n").trim();
if (combined) parts.push(`<previous_response>\n${combined}\n</previous_response>\n`);
break;
}
case "tool": {
const name = msg.name || "";
const id = msg.tool_call_id || "";
parts.push(
`<tool_result tool_call_id="${id}" name="${name}">\n${_text(msg.content)}\n</tool_result>\n`
);
break;
}
}
}
return parts.join("\n").trim();
}
export function openaiToCli(request) {
return {
prompt: messagesToPrompt(request.messages, request.tools),
model: extractModel(request.model),
sessionId: request.user,
};
}