This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7b83c275063ddfca17a8e8807ce3ea8c08464971 Author: Luigi De Masi <[email protected]> AuthorDate: Wed Oct 14 22:29:58 2026 +0200 CAMEL-25669: Add the Semantic OpenAI expert for Camel 4.24 Map native Decisions to typed Semantic results with original provider diagnostics, configurable threshold and uncertainty, and an explicitly configured model. Introduce the bridge in Camel 4.24 without adding a Semantic dependency to camel-openai. Co-authored-by: OpenAI Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- bom/camel-bom/pom.xml | 5 + catalog/camel-allcomponents/pom.xml | 5 + .../org/apache/camel/catalog/docs.properties | 1 + .../camel/catalog/docs/openai-component.adoc | 4 +- .../camel/catalog/docs/semantic-language.adoc | 4 +- .../apache/camel/catalog/docs/semantic-openai.adoc | 170 ++++++++++ .../camel/catalog/docs/typesafe-ai-component.adoc | 2 +- .../org/apache/camel/catalog/others.properties | 1 + .../camel/catalog/others/semantic-openai.json | 15 + .../src/main/docs/openai-component.adoc | 4 +- components/camel-ai/camel-semantic-openai/pom.xml | 62 ++++ .../services/org/apache/camel/other.properties | 7 + .../src/generated/resources/semantic-openai.json | 15 + .../src/main/docs/semantic-openai.adoc | 170 ++++++++++ .../openai/OpenAIDecisionsSemanticAdapter.java | 352 +++++++++++++++++++++ .../openai/OpenAIDecisionsSemanticAdapterTest.java | 339 ++++++++++++++++++++ .../src/main/docs/semantic-language.adoc | 4 +- .../src/main/docs/typesafe-ai-component.adoc | 2 +- components/camel-ai/pom.xml | 1 + .../others/examples/json/semantic-openai.json | 1 + docs/components/modules/others/nav.adoc | 1 + .../modules/others/pages/semantic-openai.adoc | 1 + parent/pom.xml | 5 + .../apache/camel/maven/packaging/MojoHelper.java | 3 +- 24 files changed, 1167 insertions(+), 7 deletions(-) diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index c172734deeac..fbcb17ed32d4 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -2192,6 +2192,11 @@ <artifactId>camel-semantic</artifactId> <version>4.23.0-SNAPSHOT</version> </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-semantic-openai</artifactId> + <version>4.23.0-SNAPSHOT</version> + </dependency> <dependency> <groupId>org.apache.camel</groupId> <artifactId>camel-servicenow</artifactId> diff --git a/catalog/camel-allcomponents/pom.xml b/catalog/camel-allcomponents/pom.xml index e7a7467cfa29..6ca49cbce535 100644 --- a/catalog/camel-allcomponents/pom.xml +++ b/catalog/camel-allcomponents/pom.xml @@ -1977,6 +1977,11 @@ <artifactId>camel-semantic</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-semantic-openai</artifactId> + <version>${project.version}</version> + </dependency> <dependency> <groupId>org.apache.camel</groupId> <artifactId>camel-servicenow</artifactId> diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties index bc3c3f2ee630..ec98922cc543 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties @@ -566,6 +566,7 @@ script-eip seda-component selective-consumer semantic-language +semantic-openai service-activator servicenow-component servlet-component diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc index 24dcf60ca7c0..fa7468334fef 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc @@ -60,7 +60,9 @@ Refusal raises `OpenAIDecisionRefusalException`, invalid provider output raises `OpenAIDecisionResponseException`, and transport/API/client-start failures raise `OpenAIDecisionServiceException`. No failure is converted into a decision value. -See the +For named Semantic evaluations, typed Boolean/Choice/Score mapping, and body image input, +add xref:others:semantic-openai.adoc[camel-semantic-openai]. The OpenAI component itself has no +dependency on `camel-semantic`. See the https://developers.openai.com/api/docs/guides/decisions[Decisions guide] for the provider contract. == URI Format diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc index 1cb7caea48c1..de2b3d50dce9 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc @@ -172,7 +172,9 @@ A negative detection does not establish general safety or grant authorization. === Text and image input Experts declare supported modalities in their operation contract. `IMAGE` allows explicit image -parts; it does not imply that every expert accepts images. TypeSafe AI accepts text and its structured state, and Wolf Defender accepts text. Unsupported input fails +parts; it does not imply that every expert accepts images. The +xref:others:semantic-openai.adoc[OpenAI Decisions expert] accepts text and images, TypeSafe AI +accepts text and its structured state, and Wolf Defender accepts text. Unsupported input fails before inference, including before any expert in a mixed batch runs. For `$\{body}`, an image `Content-Type` such as `image/png` identifies a `byte[]`, `InputStream`, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-openai.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-openai.adoc new file mode 100644 index 000000000000..7ad4b3546904 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-openai.adoc @@ -0,0 +1,170 @@ += Semantic OpenAI Component +:doctitle: Semantic OpenAI +:shortname: semantic-openai +:artifactid: camel-semantic-openai +:description: Assess text and images with typed OpenAI Decisions through Semantic experts +:since: 4.24 +:supportlevel: Preview +:tabs-sync-option: + +*Since Camel {since}* + +This module supplies an explicit expert for the xref:languages:semantic-language.adoc[Semantic language]. +It asks the https://developers.openai.com/api/docs/guides/decisions[OpenAI Decisions API] +for an assessment. The Camel route decides what action to take with that assessment. +It has no routing endpoint of its own. It depends on `camel-semantic` and `camel-openai`; +`camel-openai` remains independently usable and does not depend on Semantic. + +[source,xml] +---- +<dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-semantic-openai</artifactId> + <version>x.x.x</version> +</dependency> +---- + +== Register an expert + +The adapter uses an `openai:decisions` endpoint's managed client, credentials, base URL, +proxy, TLS, timeout and retry configuration. Configure the component as described in +xref:ROOT:openai-component.adoc[OpenAI]. It does not create a second HTTP client or +initialize route tools or MCP connections for Decisions calls. + +[source,properties] +---- +camel.component.openai.api-key={{env:OPENAI_API_KEY}} +---- + +[source,yaml] +---- +- beans: + - name: vision + type: org.apache.camel.semantic.openai.OpenAIDecisionsSemanticAdapter + properties: + endpointUri: "openai:decisions?model=gpt-6-luna" +- semantic: + evaluation: + damaged: + expert: vision + operation: boolean + state: "${body}" + parameters: + instructions: Is the package visibly damaged? + threshold: 0.5 + uncertainty: 0.1 + uncertaintyPolicy: fail +- route: + from: + uri: direct:inspect + steps: + - setVariable: + name: damaged + simple: "${semantic('damaged')}" + - to: direct:route-policy +---- + +`endpointUri` defaults to `openai:decisions`; set it before the first evaluation. +Configure a Decisions model explicitly on the endpoint, for example `model=gpt-6-luna`. +An endpoint without a model is rejected before inference. +The selected endpoint/model must implement Decisions; OpenAI-compatible chat support alone +is insufficient. There is no fallback to chat completion or structured prompting. +When constructing the bean in Java, set its `CamelContext` before binding it to the registry. +Every evaluation must explicitly name an expert. Capability inspection and declaration +validation do not perform inference. + +== Input + +Supported modalities are text, images and ordered combinations of text and images. +Audio, video, file IDs, hosted image URLs and other modalities are rejected; no transcription, +OCR, URL fetching or modality conversion service is invoked. +The native image representation follows the Decisions guide: an inline base64 data URL for +PNG, JPEG, GIF or WebP. Use the Camel-managed OpenAI Java SDK (4.78.0 or newer, which includes +Decisions). The generic API reference may describe additional forms; this adapter validates +the forms supported by this integration. + +For a single image in the message body, set `Content-Type` to the image MIME type, for example +`image/png`. The body may be a `byte[]`, `InputStream`, Camel `StreamCache`, or plain base64 +`String`. A string starting with `data:image/` is self-describing and needs no header: + +[source,text] +---- +data:image/png;base64,iVBORw0KGgo... +---- + +The example is abbreviated. Plain strings without an image content type remain text; Camel +does not guess whether an arbitrary string is base64. Content-Type applies when the selected +state is `$\{body}`; it does not reclassify a separately selected header or variable. +Streams are cached and reset before evaluation. Downstream steps receive a replayable stream +cache containing the same bytes. Byte arrays and strings keep their original body identity. + +Use an explicit envelope for multiple ordered parts, or an image selected from a header or variable: + +[source,json] +---- +{ + "$semanticInput": [ + {"type": "text", "text": "Inspect the package in this photograph."}, + {"type": "image", "data": "data:image/png;base64,...", "detail": "high"} + ] +} +---- + +Replace the abbreviated payload with actual base64. `detail` is optional and may be `auto`, +`low`, `high` or `original`. In Java, `SemanticInput.ofParts`, `text`, and `image` build this +JSON-compatible envelope; `image(byte[], mediaType)` encodes binary image data. +An ordinary application map is not implicitly treated as media. + +The expert accepts at most 128 images and 200 questions per request. Every part and question +is checked before contacting the provider. Other experts validate their own supported input: +TypeSafe AI accepts text and its structured application state, while Wolf Defender accepts text. +Neither silently converts images into text. In a batch, all experts validate input before any +expert starts inference. + +== Result contract + +[cols="1,1,3"] +|=== +|Operation |Value type |Meaning +|`boolean` |`Boolean` |Provider predicate probability compared once against the configured threshold. +|`choice` |`String` |One of the declared category keys. +|`score` |`Number` |Provider probability-weighted zero-based rubric index, including its fractional part. +|=== + +Every operation requires `instructions`. `choice` also requires a `criteria` map with 2–255 +distinct category keys and descriptions. `score` requires an ordered, nonempty `criteria` list +of distinct labels and accepts an optional `descriptions` map keyed by those labels. + +For example, `[low, medium, high]` defines indices 0, 1 and 2. A score of `1.1` is an assessment +between medium and high; it is not a Boolean probability and is not rounded to a label. +Audit and playground output retain the ordered rubric and descriptions. Score probability +keys are the decimal zero-based indices (`"0"`, `"1"`, `"2"`). + +`boolean` defaults to an inclusive threshold of `0.5`, matching TypeSafe AI's convention. +An optional `threshold` overrides it. `uncertainty` defaults to zero; a positive value defines +an inclusive band of that half-width around the threshold. Both edges must be within `[0,1]`. +Inside the band, `uncertaintyPolicy=fail` raises an exception; explicit `non-match` returns false. +The original provider probability is retained in either successful result. Camel does not +invent a confidence value or recalibrate provider uncertainty. + +Detailed probability, distribution and optional confidence are available through +`CamelSemanticResult` (single evaluation) or `CamelSemanticResults` (batch). Missing provider +fields remain absent. Named `refs:` batches use one Decisions request per expert instance and +retain declaration order. A failed or refused answer prevents publishing partial batch results. + +== Failures + +Refusal raises `OpenAIDecisionRefusalException`. Invalid or inconsistent provider output raises +`OpenAIDecisionResponseException`. Transport/API/client-start failures raise +`OpenAIDecisionServiceException`. These exceptions belong to `camel-openai` and can be handled +with the route's normal `onException` policy. Unsupported input and invalid declarations fail +validation; infrastructure failure is never mapped to false or a score. + +== Opt-in API smoke tests + +`camel-openai` includes `OpenAIDecisionsExternalServiceIT` for native text batches and mixed +text/image input. It is disabled by default. To run it against an explicitly configured service, +run `mvn verify -Pit -Dit.test=OpenAIDecisionsExternalServiceIT -Dopenai.live.tests=true` in +`components/camel-ai/camel-openai`, providing `openai.live.baseUrl`, `openai.live.apiKey` and +`openai.live.decisions.model` as system properties. These tests send requests to the service +and may incur charges. Assertions check response types and ranges, not exact probabilities. diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/typesafe-ai-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/typesafe-ai-component.adoc index 87d2621f0118..ac5c4d881a60 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/typesafe-ai-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/typesafe-ai-component.adoc @@ -313,4 +313,4 @@ The Semantic adapter accepts text and TypeSafe AI's supported structured applica It rejects explicit `$semanticInput` image/media envelopes, including nested media envelopes, and binary state before initializing transport. Selecting a body with an image `Content-Type` or a self-describing image data URL does not make this text/structured expert multimodal. -Use an image-capable expert for images. +Use an image-capable expert such as xref:others:semantic-openai.adoc[Semantic OpenAI] for images. diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties index 1698000d5524..062ad4905c0b 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties @@ -51,6 +51,7 @@ resilience4j resilience4j-micrometer resourceresolver-github rxjava +semantic-openai shiro spring-cloud-config spring-main diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/semantic-openai.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/semantic-openai.json new file mode 100644 index 000000000000..816a5b5e9edf --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/semantic-openai.json @@ -0,0 +1,15 @@ +{ + "other": { + "kind": "other", + "name": "semantic-openai", + "title": "Semantic OpenAI", + "description": "Assess text and images with typed OpenAI Decisions through Semantic experts", + "deprecated": false, + "firstVersion": "4.24.0", + "label": "ai", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-semantic-openai", + "version": "4.23.0-SNAPSHOT" + } +} diff --git a/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc b/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc index 24dcf60ca7c0..fa7468334fef 100644 --- a/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc +++ b/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc @@ -60,7 +60,9 @@ Refusal raises `OpenAIDecisionRefusalException`, invalid provider output raises `OpenAIDecisionResponseException`, and transport/API/client-start failures raise `OpenAIDecisionServiceException`. No failure is converted into a decision value. -See the +For named Semantic evaluations, typed Boolean/Choice/Score mapping, and body image input, +add xref:others:semantic-openai.adoc[camel-semantic-openai]. The OpenAI component itself has no +dependency on `camel-semantic`. See the https://developers.openai.com/api/docs/guides/decisions[Decisions guide] for the provider contract. == URI Format diff --git a/components/camel-ai/camel-semantic-openai/pom.xml b/components/camel-ai/camel-semantic-openai/pom.xml new file mode 100644 index 000000000000..5dc0208e0dc6 --- /dev/null +++ b/components/camel-ai/camel-semantic-openai/pom.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.apache.camel</groupId> + <artifactId>camel-ai-parent</artifactId> + <version>4.23.0-SNAPSHOT</version> + </parent> + <artifactId>camel-semantic-openai</artifactId> + <name>Camel :: AI :: Semantic OpenAI</name> + <description>Assess text and images with typed OpenAI Decisions through Semantic experts</description> + <properties> + <firstVersion>4.24.0</firstVersion> + <title>Semantic OpenAI</title> + <label>ai</label> + </properties> + <dependencies> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-semantic</artifactId> + </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-openai</artifactId> + </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-test-junit6</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <version>${mockito-version}</version> + <scope>test</scope> + </dependency> + </dependencies> +</project> diff --git a/components/camel-ai/camel-semantic-openai/src/generated/resources/META-INF/services/org/apache/camel/other.properties b/components/camel-ai/camel-semantic-openai/src/generated/resources/META-INF/services/org/apache/camel/other.properties new file mode 100644 index 000000000000..f68bc6f1c1d8 --- /dev/null +++ b/components/camel-ai/camel-semantic-openai/src/generated/resources/META-INF/services/org/apache/camel/other.properties @@ -0,0 +1,7 @@ +# Generated by camel build tools - do NOT edit this file! +name=semantic-openai +groupId=org.apache.camel +artifactId=camel-semantic-openai +version=4.23.0-SNAPSHOT +projectName=Camel :: AI :: Semantic OpenAI +projectDescription=Assess text and images with typed OpenAI Decisions through Semantic experts diff --git a/components/camel-ai/camel-semantic-openai/src/generated/resources/semantic-openai.json b/components/camel-ai/camel-semantic-openai/src/generated/resources/semantic-openai.json new file mode 100644 index 000000000000..816a5b5e9edf --- /dev/null +++ b/components/camel-ai/camel-semantic-openai/src/generated/resources/semantic-openai.json @@ -0,0 +1,15 @@ +{ + "other": { + "kind": "other", + "name": "semantic-openai", + "title": "Semantic OpenAI", + "description": "Assess text and images with typed OpenAI Decisions through Semantic experts", + "deprecated": false, + "firstVersion": "4.24.0", + "label": "ai", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-semantic-openai", + "version": "4.23.0-SNAPSHOT" + } +} diff --git a/components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc b/components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc new file mode 100644 index 000000000000..7ad4b3546904 --- /dev/null +++ b/components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc @@ -0,0 +1,170 @@ += Semantic OpenAI Component +:doctitle: Semantic OpenAI +:shortname: semantic-openai +:artifactid: camel-semantic-openai +:description: Assess text and images with typed OpenAI Decisions through Semantic experts +:since: 4.24 +:supportlevel: Preview +:tabs-sync-option: + +*Since Camel {since}* + +This module supplies an explicit expert for the xref:languages:semantic-language.adoc[Semantic language]. +It asks the https://developers.openai.com/api/docs/guides/decisions[OpenAI Decisions API] +for an assessment. The Camel route decides what action to take with that assessment. +It has no routing endpoint of its own. It depends on `camel-semantic` and `camel-openai`; +`camel-openai` remains independently usable and does not depend on Semantic. + +[source,xml] +---- +<dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-semantic-openai</artifactId> + <version>x.x.x</version> +</dependency> +---- + +== Register an expert + +The adapter uses an `openai:decisions` endpoint's managed client, credentials, base URL, +proxy, TLS, timeout and retry configuration. Configure the component as described in +xref:ROOT:openai-component.adoc[OpenAI]. It does not create a second HTTP client or +initialize route tools or MCP connections for Decisions calls. + +[source,properties] +---- +camel.component.openai.api-key={{env:OPENAI_API_KEY}} +---- + +[source,yaml] +---- +- beans: + - name: vision + type: org.apache.camel.semantic.openai.OpenAIDecisionsSemanticAdapter + properties: + endpointUri: "openai:decisions?model=gpt-6-luna" +- semantic: + evaluation: + damaged: + expert: vision + operation: boolean + state: "${body}" + parameters: + instructions: Is the package visibly damaged? + threshold: 0.5 + uncertainty: 0.1 + uncertaintyPolicy: fail +- route: + from: + uri: direct:inspect + steps: + - setVariable: + name: damaged + simple: "${semantic('damaged')}" + - to: direct:route-policy +---- + +`endpointUri` defaults to `openai:decisions`; set it before the first evaluation. +Configure a Decisions model explicitly on the endpoint, for example `model=gpt-6-luna`. +An endpoint without a model is rejected before inference. +The selected endpoint/model must implement Decisions; OpenAI-compatible chat support alone +is insufficient. There is no fallback to chat completion or structured prompting. +When constructing the bean in Java, set its `CamelContext` before binding it to the registry. +Every evaluation must explicitly name an expert. Capability inspection and declaration +validation do not perform inference. + +== Input + +Supported modalities are text, images and ordered combinations of text and images. +Audio, video, file IDs, hosted image URLs and other modalities are rejected; no transcription, +OCR, URL fetching or modality conversion service is invoked. +The native image representation follows the Decisions guide: an inline base64 data URL for +PNG, JPEG, GIF or WebP. Use the Camel-managed OpenAI Java SDK (4.78.0 or newer, which includes +Decisions). The generic API reference may describe additional forms; this adapter validates +the forms supported by this integration. + +For a single image in the message body, set `Content-Type` to the image MIME type, for example +`image/png`. The body may be a `byte[]`, `InputStream`, Camel `StreamCache`, or plain base64 +`String`. A string starting with `data:image/` is self-describing and needs no header: + +[source,text] +---- +data:image/png;base64,iVBORw0KGgo... +---- + +The example is abbreviated. Plain strings without an image content type remain text; Camel +does not guess whether an arbitrary string is base64. Content-Type applies when the selected +state is `$\{body}`; it does not reclassify a separately selected header or variable. +Streams are cached and reset before evaluation. Downstream steps receive a replayable stream +cache containing the same bytes. Byte arrays and strings keep their original body identity. + +Use an explicit envelope for multiple ordered parts, or an image selected from a header or variable: + +[source,json] +---- +{ + "$semanticInput": [ + {"type": "text", "text": "Inspect the package in this photograph."}, + {"type": "image", "data": "data:image/png;base64,...", "detail": "high"} + ] +} +---- + +Replace the abbreviated payload with actual base64. `detail` is optional and may be `auto`, +`low`, `high` or `original`. In Java, `SemanticInput.ofParts`, `text`, and `image` build this +JSON-compatible envelope; `image(byte[], mediaType)` encodes binary image data. +An ordinary application map is not implicitly treated as media. + +The expert accepts at most 128 images and 200 questions per request. Every part and question +is checked before contacting the provider. Other experts validate their own supported input: +TypeSafe AI accepts text and its structured application state, while Wolf Defender accepts text. +Neither silently converts images into text. In a batch, all experts validate input before any +expert starts inference. + +== Result contract + +[cols="1,1,3"] +|=== +|Operation |Value type |Meaning +|`boolean` |`Boolean` |Provider predicate probability compared once against the configured threshold. +|`choice` |`String` |One of the declared category keys. +|`score` |`Number` |Provider probability-weighted zero-based rubric index, including its fractional part. +|=== + +Every operation requires `instructions`. `choice` also requires a `criteria` map with 2–255 +distinct category keys and descriptions. `score` requires an ordered, nonempty `criteria` list +of distinct labels and accepts an optional `descriptions` map keyed by those labels. + +For example, `[low, medium, high]` defines indices 0, 1 and 2. A score of `1.1` is an assessment +between medium and high; it is not a Boolean probability and is not rounded to a label. +Audit and playground output retain the ordered rubric and descriptions. Score probability +keys are the decimal zero-based indices (`"0"`, `"1"`, `"2"`). + +`boolean` defaults to an inclusive threshold of `0.5`, matching TypeSafe AI's convention. +An optional `threshold` overrides it. `uncertainty` defaults to zero; a positive value defines +an inclusive band of that half-width around the threshold. Both edges must be within `[0,1]`. +Inside the band, `uncertaintyPolicy=fail` raises an exception; explicit `non-match` returns false. +The original provider probability is retained in either successful result. Camel does not +invent a confidence value or recalibrate provider uncertainty. + +Detailed probability, distribution and optional confidence are available through +`CamelSemanticResult` (single evaluation) or `CamelSemanticResults` (batch). Missing provider +fields remain absent. Named `refs:` batches use one Decisions request per expert instance and +retain declaration order. A failed or refused answer prevents publishing partial batch results. + +== Failures + +Refusal raises `OpenAIDecisionRefusalException`. Invalid or inconsistent provider output raises +`OpenAIDecisionResponseException`. Transport/API/client-start failures raise +`OpenAIDecisionServiceException`. These exceptions belong to `camel-openai` and can be handled +with the route's normal `onException` policy. Unsupported input and invalid declarations fail +validation; infrastructure failure is never mapped to false or a score. + +== Opt-in API smoke tests + +`camel-openai` includes `OpenAIDecisionsExternalServiceIT` for native text batches and mixed +text/image input. It is disabled by default. To run it against an explicitly configured service, +run `mvn verify -Pit -Dit.test=OpenAIDecisionsExternalServiceIT -Dopenai.live.tests=true` in +`components/camel-ai/camel-openai`, providing `openai.live.baseUrl`, `openai.live.apiKey` and +`openai.live.decisions.model` as system properties. These tests send requests to the service +and may incur charges. Assertions check response types and ranges, not exact probabilities. diff --git a/components/camel-ai/camel-semantic-openai/src/main/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapter.java b/components/camel-ai/camel-semantic-openai/src/main/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapter.java new file mode 100644 index 000000000000..d1c477e9b3fa --- /dev/null +++ b/components/camel-ai/camel-semantic-openai/src/main/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapter.java @@ -0,0 +1,352 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.semantic.openai; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.openai.models.decisions.Decision; +import com.openai.models.decisions.DecisionChoiceOption; +import com.openai.models.decisions.DecisionCreateParams; +import com.openai.models.decisions.DecisionInputImage; +import com.openai.models.decisions.DecisionInputMessage; +import com.openai.models.decisions.DecisionInputPart; +import com.openai.models.decisions.DecisionInputText; +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.ResolveEndpointFailedException; +import org.apache.camel.component.openai.OpenAIDecisionServiceException; +import org.apache.camel.component.openai.OpenAIDecisionsSupport; +import org.apache.camel.component.openai.OpenAIEndpoint; +import org.apache.camel.component.openai.OpenAIOperations; +import org.apache.camel.semantic.SemanticAdapter; +import org.apache.camel.semantic.SemanticCapabilities; +import org.apache.camel.semantic.SemanticEvaluation; +import org.apache.camel.semantic.SemanticExpert; +import org.apache.camel.semantic.SemanticExpert.InputType; +import org.apache.camel.semantic.SemanticExpert.ResultType; +import org.apache.camel.semantic.SemanticInput; +import org.apache.camel.semantic.SemanticOperation; +import org.apache.camel.semantic.SemanticParameter; +import org.apache.camel.semantic.SemanticResult; + +/** An explicitly registered expert; its OpenAI endpoint owns the client, credentials and transport lifecycle. */ +@SemanticExpert(name = "openai-decisions", provider = "openai", artifactId = "camel-semantic-openai", + description = "Typed text and image assessments using the OpenAI Decisions API", + operations = { + @SemanticOperation(name = "boolean", description = "Assess whether a condition holds", + inputTypes = { InputType.TEXT, InputType.IMAGE }, + inputRequirements = "Text, an image body with Content-Type, an image data URL, or an ordered $semanticInput envelope", + resultType = ResultType.BOOLEAN, + resultMeaning = "Predicate probability at or above threshold, subject to the configured uncertainty policy", + probability = true, + probabilityMeaning = "Provider probability that the condition is true", + parameters = { + @SemanticParameter(name = "instructions", + description = "Question to assess against the input", + required = true, minSize = 1, maxSize = 1048576), + @SemanticParameter(name = "threshold", + description = "Inclusive positive probability threshold", + type = Number.class, minimum = 0, maximum = 1, + omission = "Use 0.5"), + @SemanticParameter(name = "uncertainty", + description = "Half-width of the uncertainty band around the threshold", + type = Number.class, minimum = 0, maximum = 1, + omission = "No uncertainty band"), + @SemanticParameter(name = "uncertaintyPolicy", + description = "Behavior inside the uncertainty band", + values = { "fail", "non-match" }, + omission = "Fail the evaluation") }), + @SemanticOperation(name = "choice", description = "Select one declared category", + inputTypes = { InputType.TEXT, InputType.IMAGE }, + inputRequirements = "Text, an image body with Content-Type, an image data URL, or an ordered $semanticInput envelope", + resultType = ResultType.CHOICE, choiceLabelsParameter = "criteria", + noMatchDeclared = true, completeProbabilities = true, + resultMeaning = "The provider-selected category key", + probabilities = true, + probabilityMeaning = "Provider probability for each declared category", + confidence = true, + confidenceMeaning = "Confidence reported by OpenAI; no derived confidence is added", + parameters = { + @SemanticParameter(name = "instructions", + description = "Question to assess against the input", + required = true, minSize = 1, maxSize = 1048576), + @SemanticParameter(name = "criteria", + description = "Category keys and descriptions", + type = Map.class, itemType = String.class, + required = true, + minSize = 2, maxSize = 255) }), + @SemanticOperation(name = "score", description = "Assess input against ordered rubric levels", + inputTypes = { InputType.TEXT, InputType.IMAGE }, + inputRequirements = "Text, an image body with Content-Type, an image data URL, or an ordered $semanticInput envelope", + resultType = ResultType.SCORE, minimum = 0, scoreLevelsParameter = "criteria", + scoreDescriptionsParameter = "descriptions", completeProbabilities = true, + resultMeaning = "Provider probability-weighted zero-based rubric index; fractional scores are preserved", + probabilities = true, + probabilityMeaning = "Provider probability for each zero-based rubric index", + confidence = true, + confidenceMeaning = "Confidence reported by OpenAI; no derived confidence is added", + parameters = { + @SemanticParameter(name = "instructions", + description = "Question to assess against the input", + required = true, minSize = 1, maxSize = 1048576), + @SemanticParameter(name = "criteria", + description = "Distinct rubric labels ordered from lowest to highest", + type = List.class, itemType = String.class, + required = true, minSize = 1), + @SemanticParameter(name = "descriptions", + description = "Optional descriptions keyed by rubric label", + type = Map.class, itemType = String.class, + omission = "Use the rubric labels") }) }) +public class OpenAIDecisionsSemanticAdapter implements SemanticAdapter, CamelContextAware { + private static final SemanticCapabilities CAPABILITIES = SemanticCapabilities.from(OpenAIDecisionsSemanticAdapter.class); + private CamelContext camelContext; + private String endpointUri = "openai:decisions"; + private volatile OpenAIEndpoint endpoint; + + @Override + public CamelContext getCamelContext() { + return camelContext; + } + + @Override + public void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext; + } + + public String getEndpointUri() { + return endpointUri; + } + + /** Configured Decisions endpoint, including model and transport options. Set before the first evaluation. */ + public synchronized void setEndpointUri(String endpointUri) { + if (endpoint != null || endpointUri == null || endpointUri.isBlank()) { + throw new IllegalArgumentException("Set a nonblank Decisions endpoint URI before evaluation"); + } + this.endpointUri = endpointUri; + } + + private OpenAIEndpoint endpoint() { + if (camelContext != null && (camelContext.isStopping() || camelContext.isStopped())) { + throw new OpenAIDecisionServiceException("OpenAI Decisions requires a running CamelContext"); + } + if (endpoint == null) { + synchronized (this) { + if (endpoint == null) { + if (camelContext == null) { + throw new IllegalStateException("OpenAI Decisions expert requires a CamelContext"); + } + OpenAIEndpoint resolved; + try { + resolved = camelContext.getEndpoint(endpointUri, OpenAIEndpoint.class); + } catch (ResolveEndpointFailedException e) { + throw new OpenAIDecisionServiceException("Unable to initialize the OpenAI Decisions endpoint", e); + } + if (resolved.getOperation() != OpenAIOperations.decisions) { + throw new IllegalArgumentException("OpenAI Decisions expert requires a Decisions endpoint"); + } + endpoint = resolved; + } + } + } + return endpoint; + } + + @Override + public void validate(SemanticEvaluation evaluation) { + CAPABILITIES.validate(evaluation); + if ("boolean".equals(evaluation.getOperation())) { + double threshold = number(evaluation, "threshold", 0.5); + double uncertainty = number(evaluation, "uncertainty", 0); + if (threshold - uncertainty < 0 || threshold + uncertainty > 1) { + throw new IllegalArgumentException("Threshold and uncertainty must keep the band within [0,1]"); + } + } else if ("choice".equals(evaluation.getOperation())) { + descriptions(evaluation, "criteria").forEach((label, description) -> { + text(label); + text(description); + }); + } else { + List<String> levels = levels(evaluation); + if (new HashSet<>(levels).size() != levels.size()) { + throw new IllegalArgumentException("Score rubric labels must be distinct"); + } + levels.forEach(OpenAIDecisionsSemanticAdapter::text); + descriptions(evaluation, "descriptions").forEach((label, description) -> { + if (!levels.contains(label)) { + throw new IllegalArgumentException("Score descriptions must refer to declared rubric labels"); + } + text(description); + }); + } + } + + private static void text(String text) { + if (text.isBlank() || text.length() > 1_048_576) { + throw new IllegalArgumentException("Criteria require nonblank text within the provider text limit"); + } + } + + @Override + public void validateInput(SemanticEvaluation evaluation, Object state) { + state = SemanticInput.normalize(state); + CAPABILITIES.operation(evaluation.getOperation()).validateInput(state); + } + + @Override + public void validateState(Object state) { + OpenAIDecisionsSupport.validateInput(input(SemanticInput.normalize(state))); + } + + @Override + public SemanticResult evaluate(SemanticEvaluation evaluation, Object state) throws Exception { + return evaluateBatch(Map.of("question", evaluation), state).get("question"); + } + + @Override + public Map<String, SemanticResult> evaluateBatch(Map<String, SemanticEvaluation> evaluations, Object state) + throws Exception { + if (evaluations.isEmpty() || evaluations.size() > 200) { + throw new IllegalArgumentException("OpenAI Decisions requires 1 to 200 evaluations per batch"); + } + Object input = SemanticInput.normalize(state); + evaluations.forEach((name, evaluation) -> { + text(name); + validate(evaluation); + validateInput(evaluation, input); + }); + OpenAIEndpoint current = endpoint(); + String model = current.getConfiguration().getModel(); + if (model == null || model.isBlank()) { + throw new IllegalArgumentException("Configure a Decisions model on the OpenAI endpoint"); + } + var request = DecisionCreateParams.builder().model(model).input(input(input)); + evaluations.forEach((name, evaluation) -> request.addQuestion(question(name, evaluation))); + Decision response = current.decide(request.build()); + Map<String, Object> metadata = metadata(response); + Map<String, SemanticResult> results = new LinkedHashMap<>(); + int index = 0; + for (var entry : evaluations.entrySet()) { + SemanticResult result = result(entry.getValue(), response.answers().get(index++), metadata); + CAPABILITIES.operation(entry.getValue().getOperation()).validateResult(result); + results.put(entry.getKey(), result); + } + return results; + } + + private static DecisionCreateParams.Input input(Object state) { + if (state instanceof String text) { + return DecisionCreateParams.Input.ofString(text); + } + List<DecisionInputPart> parts = new ArrayList<>(); + for (Object value : SemanticInput.parts(state)) { + Map<?, ?> part = (Map<?, ?>) value; + if ("text".equals(part.get("type"))) { + parts.add(DecisionInputPart.ofInputText(DecisionInputText.builder().text((String) part.get("text")).build())); + } else { + var image = DecisionInputImage.builder().imageUrl((String) part.get("data")); + if (part.get("detail") instanceof String detail) { + image.detail(DecisionInputImage.Detail.of(detail)); + } + parts.add(DecisionInputPart.ofInputImage(image.build())); + } + } + return DecisionCreateParams.Input.ofDecisionInputMessages(List.of( + DecisionInputMessage.builder().contentOfParts(parts).build())); + } + + private static DecisionCreateParams.Question question(String name, SemanticEvaluation evaluation) { + String instructions = (String) evaluation.getParameters().get("instructions"); + if ("boolean".equals(evaluation.getOperation())) { + return DecisionCreateParams.Question.ofPredicate(DecisionCreateParams.Question.Predicate.builder() + .name(name).instructions(instructions).build()); + } else if ("choice".equals(evaluation.getOperation())) { + var question = DecisionCreateParams.Question.Choice.builder().name(name).instructions(instructions); + descriptions(evaluation, "criteria").forEach((label, description) -> question.addChoice( + DecisionChoiceOption.builder().value(label).description(description).build())); + return DecisionCreateParams.Question.ofChoice(question.build()); + } + var question = DecisionCreateParams.Question.Score.builder().name(name).instructions(instructions); + Map<String, String> descriptions = descriptions(evaluation, "descriptions"); + for (String label : levels(evaluation)) { + var level = DecisionCreateParams.Question.Score.Level.builder().label(label); + if (descriptions.containsKey(label)) { + level.description(descriptions.get(label)); + } + question.addLevel(level.build()); + } + return DecisionCreateParams.Question.ofScore(question.build()); + } + + private static SemanticResult result(SemanticEvaluation evaluation, Decision.Answer answer, Map<String, Object> metadata) { + if (answer.isPredicate()) { + double probability = answer.asPredicate().probability(); + double threshold = number(evaluation, "threshold", 0.5); + double uncertainty = number(evaluation, "uncertainty", 0); + boolean value = probability >= threshold; + if (uncertainty > 0 && probability >= threshold - uncertainty && probability <= threshold + uncertainty) { + if (!"non-match".equals(evaluation.getParameters().get("uncertaintyPolicy"))) { + throw new IllegalStateException("OpenAI decision probability is inside the configured uncertainty band"); + } + value = false; + } + return new SemanticResult(value, probability, Map.of(), null, metadata); + } + Map<String, Double> probabilities = new LinkedHashMap<>(); + if (answer.isChoice()) { + var choice = answer.asChoice(); + choice._probabilities().asKnown() + .ifPresent(values -> values.forEach(p -> probabilities.put(p.value().asString(), p.probability()))); + return new SemanticResult( + choice.choice().asString(), null, probabilities, choice._confidence().asKnown().orElse(null), metadata); + } + var score = answer.asScore(); + score._probabilities().asKnown() + .ifPresent(values -> values.forEach(p -> probabilities.put(Long.toString(p.value()), p.probability()))); + return new SemanticResult(score.score(), null, probabilities, score._confidence().asKnown().orElse(null), metadata); + } + + private static Map<String, Object> metadata(Decision response) { + Map<String, Object> metadata = new LinkedHashMap<>(); + metadata.put("provider", "openai"); + metadata.put("model", response.model()); + response._usage().asKnown().ifPresent(usage -> metadata.put("usage", Map.of( + "inputTokens", usage.inputTokens(), "outputTokens", usage.outputTokens(), "totalTokens", usage.totalTokens()))); + return metadata; + } + + @SuppressWarnings("unchecked") + private static List<String> levels(SemanticEvaluation evaluation) { + return (List<String>) evaluation.getParameters().get("criteria"); + } + + @SuppressWarnings("unchecked") + private static Map<String, String> descriptions(SemanticEvaluation evaluation, String parameter) { + Map<?, ?> values = (Map<?, ?>) evaluation.getParameters().getOrDefault(parameter, Map.of()); + if (values.keySet().stream().anyMatch(key -> !(key instanceof String))) { + throw new IllegalArgumentException("Criteria and description keys must be strings"); + } + return (Map<String, String>) values; + } + + private static double number(SemanticEvaluation evaluation, String parameter, double defaultValue) { + return ((Number) evaluation.getParameters().getOrDefault(parameter, defaultValue)).doubleValue(); + } +} diff --git a/components/camel-ai/camel-semantic-openai/src/test/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapterTest.java b/components/camel-ai/camel-semantic-openai/src/test/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapterTest.java new file mode 100644 index 000000000000..0e328d589780 --- /dev/null +++ b/components/camel-ai/camel-semantic-openai/src/test/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapterTest.java @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.semantic.openai; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.openai.client.OpenAIClient; +import com.openai.models.decisions.Decision; +import com.openai.models.decisions.DecisionCreateParams; +import org.apache.camel.Endpoint; +import org.apache.camel.Exchange; +import org.apache.camel.Expression; +import org.apache.camel.StreamCache; +import org.apache.camel.component.openai.OpenAIComponent; +import org.apache.camel.component.openai.OpenAIConfiguration; +import org.apache.camel.component.openai.OpenAIDecisionRefusalException; +import org.apache.camel.component.openai.OpenAIDecisionServiceException; +import org.apache.camel.component.openai.OpenAIEndpoint; +import org.apache.camel.component.openai.OpenAIOperations; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.language.semantic.SemanticLanguage; +import org.apache.camel.semantic.SemanticCapabilities; +import org.apache.camel.semantic.SemanticEvaluation; +import org.apache.camel.semantic.SemanticEvaluations; +import org.apache.camel.semantic.SemanticInput; +import org.apache.camel.semantic.SemanticResult; +import org.apache.camel.semantic.internal.SemanticAuditExtensions; +import org.apache.camel.support.DefaultExchange; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OpenAIDecisionsSemanticAdapterTest { + private DefaultCamelContext context; + private OpenAIDecisionsSemanticAdapter adapter; + private OpenAIClient client; + private Function<DecisionCreateParams, Decision> respond; + + @BeforeEach + void setup() throws Exception { + context = new DefaultCamelContext(); + client = mock(OpenAIClient.class, RETURNS_DEEP_STUBS); + when(client.decisions().create(any(DecisionCreateParams.class))).thenAnswer(call -> respond.apply(call.getArgument(0))); + OpenAIComponent component = new OpenAIComponent(); + component.setCamelContext(context); + OpenAIEndpoint endpoint = new OpenAIEndpoint("openai:decisions", component, configuration()) { + @Override + protected OpenAIClient createClient() { + return client; + } + }; + endpoint.setOperation(OpenAIOperations.decisions); + context.addEndpoint("openai:decisions", endpoint); + adapter = new OpenAIDecisionsSemanticAdapter(); + adapter.setCamelContext(context); + context.getRegistry().bind("expert", adapter); + context.start(); + } + + @AfterEach + void stop() throws Exception { + context.close(); + } + + @Test + void preservesBooleanProbabilityAndExchangeBody() { + var evaluation = evaluation("boolean", Map.of()); + Expression expression = expression(evaluation); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + respond = request -> response(predicate("question", 0.96)); + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody("original evidence"); + assertThat(expression.evaluate(exchange, Boolean.class)).isTrue(); + assertThat(exchange.getMessage().getBody()).isEqualTo("original evidence"); + SemanticResult result = exchange.getProperty(SemanticLanguage.RESULT, SemanticResult.class); + assertThat(result.getProbability()).isEqualTo(0.96); + assertThat(result.getConfidence()).isNull(); + assertThat(result.getMetadata()).containsEntry("provider", "openai"); + } + + @Test + void imageBodyFormsReachTheProviderAndStreamsRemainReplayable() { + Expression expression = expression(evaluation("boolean", Map.of())); + respond = request -> { + assertThat(request.input().asDecisionInputMessages().get(0).content().asParts().get(0) + .asInputImage().imageUrl()).isEqualTo("data:image/png;base64,AQID"); + return response(predicate("question", 0.9)); + }; + byte[] bytes = new byte[] { 1, 2, 3 }; + for (Object body : List.of(bytes, new ByteArrayInputStream(bytes), "AQID", "data:image/png;base64,AQID")) { + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody(body); + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "image/png"); + assertThat(expression.evaluate(exchange, Boolean.class)).isTrue(); + if (exchange.getMessage().getBody() instanceof StreamCache cache) { + assertThat(exchange.getMessage().getBody(byte[].class)).containsExactly(bytes); + cache.reset(); + assertThat(expression.evaluate(exchange, Boolean.class)).isTrue(); + } else { + assertThat(exchange.getMessage().getBody()).isSameAs(body); + } + } + verify(client.decisions(), times(5)).create(any(DecisionCreateParams.class)); + } + + @Test + void selectedTextHeaderDoesNotInheritTheImageBodyContentType() { + var evaluation = new SemanticEvaluation("boolean", "expert", "${header.evidence}", Map.of("instructions", "Assess")); + Expression expression = expression(evaluation); + respond = request -> { + assertThat(request.input().asString()).isEqualTo("Selected text"); + return response(predicate("question", 0.9)); + }; + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody(new byte[] { 1 }); + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "image/png"); + exchange.getMessage().setHeader("evidence", "Selected text"); + assertThat(expression.evaluate(exchange, Boolean.class)).isTrue(); + } + + @Test + void appliesThresholdAndUncertaintyExactlyOnce() throws Exception { + respond = request -> response(predicate("question", 0.5)); + assertThat(adapter.evaluate(evaluation("boolean", Map.of()), "input").getValue()).isEqualTo(true); + assertThat(adapter.evaluate(evaluation("boolean", Map.of("threshold", 0.6)), "input").getValue()).isEqualTo(false); + assertThatThrownBy(() -> adapter.evaluate(evaluation("boolean", Map.of("uncertainty", 0.1)), "input")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("uncertainty band"); + var result = adapter.evaluate(evaluation("boolean", Map.of("uncertainty", 0.1, "uncertaintyPolicy", "non-match")), + "input"); + assertThat(result.getValue()).isEqualTo(false); + assertThat(result.getProbability()).isEqualTo(0.5); + assertThatThrownBy(() -> adapter.validate(evaluation("boolean", Map.of("threshold", 0.95, "uncertainty", 0.1)))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void mapsFractionalScoreAndOrderedRichRubric() throws Exception { + var evaluation = evaluation("score", Map.of("criteria", List.of("low", "medium", "high"), + "descriptions", Map.of("medium", "Workaround exists", "high", "Completely blocked"))); + respond = request -> response(Decision.Answer.ofScore(Decision.Answer.Score.builder().name("question").score(1.1) + .confidence(0.82) + .addProbability(Decision.Answer.Score.Probability.builder().value(0).label("low").probability(0.1).build()) + .addProbability(Decision.Answer.Score.Probability.builder().value(1).label("medium").probability(0.7).build()) + .addProbability(Decision.Answer.Score.Probability.builder().value(2).label("high").probability(0.2).build()) + .build())); + var result = adapter.evaluate(evaluation, "input"); + assertThat(result.getValue()).isEqualTo(1.1); + assertThat(result.getProbabilities()).containsExactlyInAnyOrderEntriesOf(Map.of("0", 0.1, "1", 0.7, "2", 0.2)); + assertThat(result.getConfidence()).isEqualTo(0.82); + ArgumentCaptor<DecisionCreateParams> capture = ArgumentCaptor.forClass(DecisionCreateParams.class); + verify(client.decisions()).create(capture.capture()); + assertThat(capture.getValue().questions().get(0).asScore().levels()).extracting(l -> l.label()) + .containsExactly("low", "medium", "high"); + assertThat(capture.getValue().questions().get(0).asScore().levels().get(1).description()).contains("Workaround exists"); + var interpretation + = SemanticAuditExtensions.capture(evaluation, SemanticCapabilities.from(adapter.getClass()).operation("score")); + assertThat(interpretation).containsEntry("scoreLevels", List.of("low", "medium", "high")) + .containsEntry("scoreDescriptions", Map.of("medium", "Workaround exists", "high", "Completely blocked")); + } + + @Test + void returnsStringChoiceWithNativeDistribution() throws Exception { + respond = request -> response(Decision.Answer.ofChoice(Decision.Answer.Choice.builder().name("question") + .choice("billing").confidence(0.8) + .addProbability(Decision.Answer.Choice.Probability.builder().value("billing").probability(0.9).build()) + .addProbability(Decision.Answer.Choice.Probability.builder().value("technical").probability(0.1).build()) + .build())); + var result = adapter.evaluate( + evaluation("choice", Map.of("criteria", Map.of("billing", "Payments", "technical", "Bugs"))), "input"); + assertThat(result.getValue()).isEqualTo("billing"); + assertThat(result.getProbabilities()).containsEntry("billing", 0.9); + assertThat(result.getConfidence()).isEqualTo(0.8); + } + + @Test + void validatesAllInputsAndParametersBeforeCallingProvider() { + var evaluation = evaluation("boolean", Map.of()); + for (Object input : List.of(new byte[] { 1 }, Map.of("state", "text"), + Map.of(SemanticInput.PARTS, List.of(Map.of("type", "audio", "data", "content"))), + SemanticInput.ofParts(List.of(SemanticInput.image("https://example.com/image.png"))))) { + assertThatThrownBy(() -> adapter.evaluate(evaluation, input)).isInstanceOf(IllegalArgumentException.class); + } + var batch = new LinkedHashMap<String, SemanticEvaluation>(); + batch.put("valid", evaluation); + batch.put("invalid", evaluation("score", Map.of("criteria", List.of("same", "same")))); + assertThatThrownBy(() -> adapter.evaluateBatch(batch, "input")).isInstanceOf(IllegalArgumentException.class); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + @Test + void sharedMediaBatchPreservesPartOrderAndFailsAtomically() throws Exception { + var batch = new LinkedHashMap<String, SemanticEvaluation>(); + batch.put("first", evaluation("boolean", Map.of())); + batch.put("second", evaluation("boolean", Map.of())); + var input = SemanticInput.ofParts(List.of(SemanticInput.text("Inspect"), + Map.of("type", "image", "data", "data:image/png;base64,AA==", "detail", "high"), + SemanticInput.text("and explain"))); + respond = request -> response(predicate("first", 0.9), predicate("second", 0.2)); + assertThat(adapter.evaluateBatch(batch, input)).hasSize(2); + ArgumentCaptor<DecisionCreateParams> capture = ArgumentCaptor.forClass(DecisionCreateParams.class); + verify(client.decisions()).create(capture.capture()); + var parts = capture.getValue().input().asDecisionInputMessages().get(0).content().asParts(); + assertThat(parts.get(0).asInputText().text()).isEqualTo("Inspect"); + assertThat(parts.get(1).asInputImage().detail().orElseThrow().asString()).isEqualTo("high"); + assertThat(parts.get(2).asInputText().text()).isEqualTo("and explain"); + respond = request -> response(predicate("first", 0.9), + Decision.Answer.ofRefusal(Decision.Answer.Refusal.builder().name("second").build())); + assertThatThrownBy(() -> adapter.evaluateBatch(batch, input)).isInstanceOf(OpenAIDecisionRefusalException.class); + } + + @Test + void failedEvaluationClearsPreviousResult() { + Expression expression = expression(evaluation("boolean", Map.of())); + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody("text"); + respond = request -> response(predicate("question", 0.9)); + assertThat(expression.evaluate(exchange, Boolean.class)).isTrue(); + respond = request -> response(Decision.Answer.ofRefusal(Decision.Answer.Refusal.builder().name("question").build())); + assertThatThrownBy(() -> expression.evaluate(exchange, Boolean.class)) + .isInstanceOf(OpenAIDecisionRefusalException.class); + assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull(); + } + + @Test + void lazyClientStartupFailureUsesTheDedicatedServiceException() { + context.addComponent("broken", new OpenAIComponent() { + @Override + protected Endpoint createEndpoint(String uri, String remaining, Map<String, Object> parameters) { + var endpoint = new OpenAIEndpoint(uri, this, configuration()) { + @Override + protected OpenAIClient createClient() throws Exception { + throw new IOException("Client configuration unavailable"); + } + }; + endpoint.setOperation(OpenAIOperations.decisions); + return endpoint; + } + }); + adapter.setEndpointUri("broken:decisions"); + assertThatThrownBy(() -> adapter.evaluate(evaluation("boolean", Map.of()), "text")) + .isInstanceOf(OpenAIDecisionServiceException.class).hasRootCauseInstanceOf(IOException.class); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + @Test + void stoppedContextCannotRestartTheProviderClient() throws Exception { + context.stop(); + assertThatThrownBy(() -> adapter.evaluate(evaluation("boolean", Map.of()), "text")) + .isInstanceOf(OpenAIDecisionServiceException.class); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + @Test + void directLanguageEvaluationRejectsMalformedImageBeforeInference() { + var language = (SemanticLanguage) context.resolveLanguage("semantic"); + assertThatThrownBy(() -> language.evaluate(evaluation("boolean", Map.of()), "data:image/png;base64,invalid!")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("base64"); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + @Test + void requiresAnExplicitModelBeforeInference() { + context.getEndpoint("openai:decisions", OpenAIEndpoint.class).getConfiguration().setModel(null); + assertThatThrownBy(() -> adapter.evaluate(evaluation("boolean", Map.of()), "text")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Configure a Decisions model"); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + @Test + void binaryBodySelectorsRequireTheExplicitBodyImagePath() { + var evaluation = new SemanticEvaluation("boolean", "expert", "${bodyAs(byte[])}", Map.of("instructions", "Assess")); + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody(new byte[] { 1, 2, 3 }); + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "image/png"); + assertThatThrownBy(() -> expression(evaluation).evaluate(exchange, Boolean.class)) + .hasRootCauseInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unsupported state type"); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + private static OpenAIConfiguration configuration() { + var configuration = new OpenAIConfiguration(); + configuration.setModel("gpt-6-luna"); + return configuration; + } + + private Expression expression(SemanticEvaluation evaluation) { + SemanticEvaluations.get(context).replace("test", Map.of("q", evaluation)); + return context.resolveLanguage("semantic").createExpression("ref:q"); + } + + private static SemanticEvaluation evaluation(String operation, Map<String, Object> parameters) { + var values = new LinkedHashMap<String, Object>(parameters); + values.put("instructions", "Assess the evidence"); + return new SemanticEvaluation(operation, "expert", null, values); + } + + private static Decision.Answer predicate(String name, double probability) { + return Decision.Answer.ofPredicate(Decision.Answer.Predicate.builder().name(name).probability(probability).build()); + } + + private static Decision response(Decision.Answer... answers) { + return Decision.builder().model("gpt-6-luna").answers(List.of(answers)) + .usage(Decision.Usage.builder().inputTokens(1).outputTokens(0).totalTokens(1) + .inputTokensDetails( + Decision.Usage.InputTokensDetails.builder().cachedTokens(0).cacheWriteTokens(0).build()) + .outputTokensDetails(Decision.Usage.OutputTokensDetails.builder().reasoningTokens(0).build()).build()) + .build(); + } +} diff --git a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc index 1cb7caea48c1..de2b3d50dce9 100644 --- a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc +++ b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc @@ -172,7 +172,9 @@ A negative detection does not establish general safety or grant authorization. === Text and image input Experts declare supported modalities in their operation contract. `IMAGE` allows explicit image -parts; it does not imply that every expert accepts images. TypeSafe AI accepts text and its structured state, and Wolf Defender accepts text. Unsupported input fails +parts; it does not imply that every expert accepts images. The +xref:others:semantic-openai.adoc[OpenAI Decisions expert] accepts text and images, TypeSafe AI +accepts text and its structured state, and Wolf Defender accepts text. Unsupported input fails before inference, including before any expert in a mixed batch runs. For `$\{body}`, an image `Content-Type` such as `image/png` identifies a `byte[]`, `InputStream`, diff --git a/components/camel-ai/camel-typesafe-ai/src/main/docs/typesafe-ai-component.adoc b/components/camel-ai/camel-typesafe-ai/src/main/docs/typesafe-ai-component.adoc index 87d2621f0118..ac5c4d881a60 100644 --- a/components/camel-ai/camel-typesafe-ai/src/main/docs/typesafe-ai-component.adoc +++ b/components/camel-ai/camel-typesafe-ai/src/main/docs/typesafe-ai-component.adoc @@ -313,4 +313,4 @@ The Semantic adapter accepts text and TypeSafe AI's supported structured applica It rejects explicit `$semanticInput` image/media envelopes, including nested media envelopes, and binary state before initializing transport. Selecting a body with an image `Content-Type` or a self-describing image data URL does not make this text/structured expert multimodal. -Use an image-capable expert for images. +Use an image-capable expert such as xref:others:semantic-openai.adoc[Semantic OpenAI] for images. diff --git a/components/camel-ai/pom.xml b/components/camel-ai/pom.xml index 7d14a9355155..bbb32c3af7ab 100644 --- a/components/camel-ai/pom.xml +++ b/components/camel-ai/pom.xml @@ -44,6 +44,7 @@ <module>camel-docling</module> <module>camel-huggingface</module> <module>camel-semantic</module> + <module>camel-semantic-openai</module> <module>camel-typesafe-ai</module> <module>camel-wolf-defender</module> <module>camel-kserve</module> diff --git a/docs/components/modules/others/examples/json/semantic-openai.json b/docs/components/modules/others/examples/json/semantic-openai.json new file mode 120000 index 000000000000..628af3bf2776 --- /dev/null +++ b/docs/components/modules/others/examples/json/semantic-openai.json @@ -0,0 +1 @@ +../../../../../../components/camel-ai/camel-semantic-openai/src/generated/resources/semantic-openai.json \ No newline at end of file diff --git a/docs/components/modules/others/nav.adoc b/docs/components/modules/others/nav.adoc index ffd67939a772..45606aec326d 100644 --- a/docs/components/modules/others/nav.adoc +++ b/docs/components/modules/others/nav.adoc @@ -87,6 +87,7 @@ ** xref:salesforce-reports.adoc[Salesforce - Reports API] ** xref:salesforce-rest-api.adoc[Salesforce - REST API] ** xref:salesforce-streaming.adoc[Salesforce - Streaming and Pub/Sub] +** xref:semantic-openai.adoc[Semantic OpenAI] ** xref:shiro.adoc[Shiro] ** xref:spring-cloud-config.adoc[Spring Cloud Config] ** xref:spring-main.adoc[Spring Main] diff --git a/docs/components/modules/others/pages/semantic-openai.adoc b/docs/components/modules/others/pages/semantic-openai.adoc new file mode 120000 index 000000000000..2aba60fadf6d --- /dev/null +++ b/docs/components/modules/others/pages/semantic-openai.adoc @@ -0,0 +1 @@ +../../../../../components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc \ No newline at end of file diff --git a/parent/pom.xml b/parent/pom.xml index 73ac4037d8c7..61e7efc31fd7 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -2703,6 +2703,11 @@ <artifactId>camel-semantic</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-semantic-openai</artifactId> + <version>${project.version}</version> + </dependency> <dependency> <groupId>org.apache.camel</groupId> <artifactId>camel-servicenow</artifactId> diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java index abf900939b5f..1cf6a7833448 100644 --- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java +++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java @@ -42,7 +42,8 @@ public final class MojoHelper { dir.resolve("camel-ai-resource"), dir.resolve("camel-ai-tool"), dir.resolve("camel-chatscript"), dir.resolve("camel-djl"), dir.resolve("camel-huggingface"), - dir.resolve("camel-semantic"), dir.resolve("camel-typesafe-ai"), dir.resolve("camel-wolf-defender"), + dir.resolve("camel-semantic"), dir.resolve("camel-semantic-openai"), dir.resolve("camel-typesafe-ai"), + dir.resolve("camel-wolf-defender"), dir.resolve("camel-langchain4j-agent"), dir.resolve("camel-langchain4j-chat"), dir.resolve("camel-langchain4j-embeddings"), dir.resolve("camel-langchain4j-embeddingstore"), dir.resolve("camel-langchain4j-ingest"),
