qtype 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. qtype/commands/convert.py +18 -5
  2. qtype/commands/generate.py +16 -8
  3. qtype/commands/run.py +6 -83
  4. qtype/commands/serve.py +73 -0
  5. qtype/commands/validate.py +18 -8
  6. qtype/commands/visualize.py +87 -0
  7. qtype/commons/generate.py +9 -4
  8. qtype/converters/tools_from_module.py +69 -134
  9. qtype/converters/types.py +47 -1
  10. qtype/dsl/base_types.py +0 -1
  11. qtype/dsl/custom_types.py +73 -0
  12. qtype/dsl/document.py +27 -3
  13. qtype/dsl/domain_types.py +3 -0
  14. qtype/dsl/model.py +60 -73
  15. qtype/dsl/validator.py +20 -0
  16. qtype/interpreter/api.py +49 -13
  17. qtype/interpreter/chat/chat_api.py +237 -0
  18. qtype/interpreter/chat/file_conversions.py +57 -0
  19. qtype/interpreter/chat/vercel.py +314 -0
  20. qtype/interpreter/conversions.py +2 -0
  21. qtype/interpreter/steps/llm_inference.py +44 -19
  22. qtype/interpreter/streaming_helpers.py +123 -0
  23. qtype/interpreter/typing.py +29 -10
  24. qtype/interpreter/ui/404/index.html +1 -0
  25. qtype/interpreter/ui/404.html +1 -0
  26. qtype/interpreter/ui/_next/static/chunks/4bd1b696-cf72ae8a39fa05aa.js +1 -0
  27. qtype/interpreter/ui/_next/static/chunks/736-7fc606e244fedcb1.js +36 -0
  28. qtype/interpreter/ui/_next/static/chunks/964-ed4ab073db645007.js +1 -0
  29. qtype/interpreter/ui/_next/static/chunks/app/_not-found/page-e110d2a9d0a83d82.js +1 -0
  30. qtype/interpreter/ui/_next/static/chunks/app/layout-107b589eb751bfb7.js +1 -0
  31. qtype/interpreter/ui/_next/static/chunks/app/page-c72e847e888e549d.js +1 -0
  32. qtype/interpreter/ui/_next/static/chunks/ba12c10f-22556063851a6df2.js +1 -0
  33. qtype/interpreter/ui/_next/static/chunks/framework-7c95b8e5103c9e90.js +1 -0
  34. qtype/interpreter/ui/_next/static/chunks/main-6d261b6c5d6fb6c2.js +1 -0
  35. qtype/interpreter/ui/_next/static/chunks/main-app-6fc6346bc8f7f163.js +1 -0
  36. qtype/interpreter/ui/_next/static/chunks/pages/_app-0a0020ddd67f79cf.js +1 -0
  37. qtype/interpreter/ui/_next/static/chunks/pages/_error-03529f2c21436739.js +1 -0
  38. qtype/interpreter/ui/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  39. qtype/interpreter/ui/_next/static/chunks/webpack-2218571d248e99d2.js +1 -0
  40. qtype/interpreter/ui/_next/static/css/d4ad601c4774485e.css +3 -0
  41. qtype/interpreter/ui/_next/static/dBTVLoSkoaoznQv-yROk9/_buildManifest.js +1 -0
  42. qtype/interpreter/ui/_next/static/dBTVLoSkoaoznQv-yROk9/_ssgManifest.js +1 -0
  43. qtype/interpreter/ui/_next/static/media/569ce4b8f30dc480-s.p.woff2 +0 -0
  44. qtype/interpreter/ui/_next/static/media/747892c23ea88013-s.woff2 +0 -0
  45. qtype/interpreter/ui/_next/static/media/8d697b304b401681-s.woff2 +0 -0
  46. qtype/interpreter/ui/_next/static/media/93f479601ee12b01-s.p.woff2 +0 -0
  47. qtype/interpreter/ui/_next/static/media/9610d9e46709d722-s.woff2 +0 -0
  48. qtype/interpreter/ui/_next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
  49. qtype/interpreter/ui/favicon.ico +0 -0
  50. qtype/interpreter/ui/file.svg +1 -0
  51. qtype/interpreter/ui/globe.svg +1 -0
  52. qtype/interpreter/ui/index.html +1 -0
  53. qtype/interpreter/ui/index.txt +22 -0
  54. qtype/interpreter/ui/next.svg +1 -0
  55. qtype/interpreter/ui/vercel.svg +1 -0
  56. qtype/interpreter/ui/window.svg +1 -0
  57. qtype/loader.py +57 -8
  58. qtype/semantic/generate.py +17 -5
  59. qtype/semantic/model.py +16 -24
  60. qtype/semantic/visualize.py +485 -0
  61. {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/METADATA +28 -20
  62. qtype-0.0.6.dist-info/RECORD +91 -0
  63. qtype-0.0.4.dist-info/RECORD +0 -50
  64. {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/WHEEL +0 -0
  65. {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/entry_points.txt +0 -0
  66. {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/licenses/LICENSE +0 -0
  67. {qtype-0.0.4.dist-info → qtype-0.0.6.dist-info}/top_level.txt +0 -0
qtype/dsl/model.py CHANGED
@@ -3,9 +3,15 @@ from __future__ import annotations
3
3
  import inspect
4
4
  from abc import ABC
5
5
  from enum import Enum
6
- from typing import Any, Type, Union
7
-
8
- from pydantic import Field, RootModel, model_validator
6
+ from typing import Any, Literal, Type, Union
7
+
8
+ from pydantic import (
9
+ BaseModel,
10
+ Field,
11
+ RootModel,
12
+ ValidationInfo,
13
+ model_validator,
14
+ )
9
15
 
10
16
  import qtype.dsl.domain_types as domain_types
11
17
  from qtype.dsl.base_types import PrimitiveTypeEnum, StrictBaseModel
@@ -26,7 +32,9 @@ DOMAIN_CLASSES = {
26
32
  }
27
33
 
28
34
 
29
- def _resolve_variable_type(parsed_type: Any) -> Any:
35
+ def _resolve_variable_type(
36
+ parsed_type: Any, custom_type_registry: dict[str, Type[BaseModel]]
37
+ ) -> Any:
30
38
  """Resolve a type string to its corresponding PrimitiveTypeEnum or return as is."""
31
39
  # If the type is already resolved or is a structured definition, pass it through.
32
40
  if not isinstance(parsed_type, str):
@@ -44,12 +52,16 @@ def _resolve_variable_type(parsed_type: Any) -> Any:
44
52
  if parsed_type in DOMAIN_CLASSES:
45
53
  return DOMAIN_CLASSES[parsed_type]
46
54
 
55
+ # Check the registry of dynamically created custom types
56
+ if parsed_type in custom_type_registry:
57
+ return custom_type_registry[parsed_type]
58
+
47
59
  # If it's not a primitive or a known domain entity, return it as a string.
48
60
  # This assumes it might be a reference ID to another custom type.
49
61
  return parsed_type
50
62
 
51
63
 
52
- class Variable(StrictBaseModel):
64
+ class Variable(BaseModel):
53
65
  """Schema for a variable that can serve as input, output, or parameter within the DSL."""
54
66
 
55
67
  id: str = Field(
@@ -58,67 +70,45 @@ class Variable(StrictBaseModel):
58
70
  )
59
71
  type: VariableType | str = Field(
60
72
  ...,
61
- description=("Type of data expected or produced."),
73
+ description=(
74
+ "Type of data expected or produced. Either a CustomType or domain specific type."
75
+ ),
62
76
  )
63
77
 
64
78
  @model_validator(mode="before")
65
79
  @classmethod
66
- def resolve_type(cls, data: Any) -> Any:
67
- if isinstance(data, dict) and "type" in data:
68
- data["type"] = _resolve_variable_type(data["type"]) # type: ignore
80
+ def resolve_type(cls, data: Any, info: ValidationInfo) -> Any:
81
+ """
82
+ This validator runs during the main validation pass. It uses the
83
+ context to resolve string-based type references.
84
+ """
85
+ if (
86
+ isinstance(data, dict)
87
+ and "type" in data
88
+ and isinstance(data["type"], str)
89
+ ):
90
+ # Get the registry of custom types from the validation context.
91
+ custom_types = (info.context or {}).get("custom_types", {})
92
+ resolved = _resolve_variable_type(data["type"], custom_types)
93
+ # {'id': 'user_message', 'type': 'ChatMessage'}
94
+ data["type"] = resolved
69
95
  return data
70
96
 
71
97
 
72
- class TypeDefinitionBase(StrictBaseModel, ABC):
73
- id: str = Field(description="The unique identifier for this custom type.")
74
- kind: StructuralTypeEnum = Field(
75
- ...,
76
- description="The kind of structure this type represents (object/array).",
77
- )
78
- description: str | None = Field(
79
- None, description="A description of what this type represents."
80
- )
98
+ class CustomType(StrictBaseModel):
99
+ """A simple declaration of a custom data type by the user."""
81
100
 
101
+ id: str
102
+ description: str | None = None
103
+ properties: dict[str, str]
82
104
 
83
- class ObjectTypeDefinition(TypeDefinitionBase):
84
- kind: StructuralTypeEnum = StructuralTypeEnum.object
85
- properties: dict[str, VariableType | str] | None = Field(
86
- None, description="Defines the nested properties."
87
- )
88
105
 
89
- @model_validator(mode="after")
90
- def resolve_type(self) -> "ObjectTypeDefinition":
91
- """Resolve the type string to its corresponding PrimitiveTypeEnum."""
92
- # Pydantic doesn't properly handle enums as strings in model validation,
93
- if self.properties:
94
- for key, value in self.properties.items():
95
- if isinstance(value, str):
96
- self.properties[key] = _resolve_variable_type(value)
97
- return self
98
-
99
-
100
- class ArrayTypeDefinition(TypeDefinitionBase):
101
- kind: StructuralTypeEnum = StructuralTypeEnum.array
102
- type: VariableType | str = Field(
103
- ..., description="The type of items in the array."
104
- )
105
-
106
- @model_validator(mode="before")
107
- @classmethod
108
- def resolve_type(cls, data: Any) -> Any:
109
- if isinstance(data, dict) and "type" in data:
110
- # If the type is a string, resolve it to PrimitiveTypeEnum or Domain Entity class.
111
- data["type"] = _resolve_variable_type(data["type"]) # type: ignore
112
- return data
113
-
114
-
115
- TypeDefinition = ObjectTypeDefinition | ArrayTypeDefinition
116
106
  VariableType = (
117
107
  PrimitiveTypeEnum
118
- | TypeDefinition
119
108
  | Type[Embedding]
120
109
  | Type[ChatMessage]
121
110
  | Type[ChatContent]
111
+ | Type[BaseModel]
122
112
  )
123
113
 
124
114
 
@@ -321,6 +311,12 @@ class Flow(Step):
321
311
  the first and last step, respectively.
322
312
  """
323
313
 
314
+ description: str | None = Field(
315
+ default=None, description="Optional description of the flow."
316
+ )
317
+
318
+ mode: Literal["Complete", "Chat"] = "Complete"
319
+
324
320
  steps: list[StepType | str] = Field(
325
321
  default_factory=list, description="List of steps or step IDs."
326
322
  )
@@ -422,7 +418,15 @@ class TelemetrySink(StrictBaseModel):
422
418
 
423
419
 
424
420
  class Application(StrictBaseModel):
425
- """Defines a QType application that can include models, variables, and other components."""
421
+ """Defines a complete QType application specification.
422
+
423
+ An Application is the top-level container of the entire
424
+ program in a QType YAML file. It serves as the blueprint for your
425
+ AI-powered application, containing all the models, flows, tools, data sources,
426
+ and configuration needed to run your program. Think of it as the main entry
427
+ point that ties together all components into a cohesive,
428
+ executable system.
429
+ """
426
430
 
427
431
  id: str = Field(..., description="Unique ID of the application.")
428
432
  description: str | None = Field(
@@ -437,7 +441,7 @@ class Application(StrictBaseModel):
437
441
  models: list[ModelType] | None = Field(
438
442
  default=None, description="List of models used in this application."
439
443
  )
440
- types: list[TypeDefinition] | None = Field(
444
+ types: list[CustomType] | None = Field(
441
445
  default=None,
442
446
  description="List of custom types defined in this application.",
443
447
  )
@@ -540,24 +544,7 @@ class VectorSearch(Search):
540
544
  ]
541
545
 
542
546
  if self.outputs is None:
543
- self.outputs = [
544
- Variable(
545
- id=f"{self.id}.results",
546
- type=ArrayTypeDefinition(
547
- id=f"{self.id}.SearchResult",
548
- type=ObjectTypeDefinition(
549
- id="SearchResult",
550
- description="Result of a search operation.",
551
- properties={
552
- "score": PrimitiveTypeEnum.number,
553
- "id": PrimitiveTypeEnum.text,
554
- "document": PrimitiveTypeEnum.text,
555
- },
556
- ),
557
- description=None,
558
- ),
559
- )
560
- ]
547
+ self.outputs = [Variable(id=f"{self.id}.results", type=Embedding)]
561
548
  return self
562
549
 
563
550
 
@@ -639,10 +626,10 @@ class ToolList(RootModel[list[ToolType]]):
639
626
  root: list[ToolType]
640
627
 
641
628
 
642
- class TypeList(RootModel[list[TypeDefinition]]):
629
+ class TypeList(RootModel[list[CustomType]]):
643
630
  """Schema for a standalone list of type definitions."""
644
631
 
645
- root: list[TypeDefinition]
632
+ root: list[CustomType]
646
633
 
647
634
 
648
635
  class VariableList(RootModel[list[Variable]]):
qtype/dsl/validator.py CHANGED
@@ -436,4 +436,24 @@ def validate(
436
436
  for obj_id, obj in lookup_map.items()
437
437
  }
438
438
 
439
+ # If any chat flow doesn't have an input variable that is a chat message, raise an error.
440
+ for flow in dsl_application.flows or []:
441
+ if flow.mode == "Chat":
442
+ inputs = flow.inputs or []
443
+ if not any(
444
+ input_var.type == qtype.dsl.domain_types.ChatMessage
445
+ for input_var in inputs
446
+ if isinstance(input_var, dsl.Variable)
447
+ ):
448
+ raise QTypeValidationError(
449
+ f"Chat flow {flow.id} must have at least one input variable of type ChatMessage."
450
+ )
451
+ if (
452
+ len(flow.outputs) != 1
453
+ or flow.outputs[0].type != qtype.dsl.domain_types.ChatMessage
454
+ ): # type: ignore
455
+ raise QTypeValidationError(
456
+ f"Chat flow {flow.id} must have exactly one output variable of type ChatMessage."
457
+ )
458
+
439
459
  return dsl_application
qtype/interpreter/api.py CHANGED
@@ -1,6 +1,10 @@
1
- from typing import Optional
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
2
4
 
3
5
  from fastapi import FastAPI, HTTPException
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.staticfiles import StaticFiles
4
8
 
5
9
  from qtype.interpreter.flow import execute_flow
6
10
  from qtype.interpreter.typing import (
@@ -23,19 +27,54 @@ class APIExecutor:
23
27
  self.host = host
24
28
  self.port = port
25
29
 
26
- def create_app(self, name: Optional[str]) -> FastAPI:
30
+ def create_app(
31
+ self,
32
+ name: str | None = None,
33
+ ui_enabled: bool = True,
34
+ fast_api_args: dict | None = None,
35
+ ) -> FastAPI:
27
36
  """Create FastAPI app with dynamic endpoints."""
28
- app = FastAPI(
29
- title=name or "QType API",
30
- docs_url="/docs", # Swagger UI
31
- redoc_url="/redoc",
32
- )
37
+ if fast_api_args is None:
38
+ fast_api_args = {
39
+ "docs_url": "/docs",
40
+ "redoc_url": "/redoc",
41
+ }
42
+
43
+ app = FastAPI(title=name or "QType API", **fast_api_args)
44
+
45
+ # Serve static UI files if they exist
46
+ if ui_enabled:
47
+ # Add CORS middleware only for localhost development
48
+ if self.host in ("localhost", "127.0.0.1", "0.0.0.0"):
49
+ app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origins=["*"],
52
+ allow_credentials=True,
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+ ui_dir = Path(__file__).parent / "ui"
57
+ if ui_dir.exists():
58
+ app.mount(
59
+ "/ui",
60
+ StaticFiles(directory=str(ui_dir), html=True),
61
+ name="ui",
62
+ )
33
63
 
34
64
  flows = self.definition.flows if self.definition.flows else []
35
65
 
36
66
  # Dynamically generate POST endpoints for each flow
37
67
  for flow in flows:
38
- self._create_flow_endpoint(app, flow)
68
+ if flow.mode == "Chat":
69
+ # For chat, we can create a single endpoint
70
+ # that handles the chat interactions
71
+ from qtype.interpreter.chat.chat_api import (
72
+ create_chat_flow_endpoint,
73
+ )
74
+
75
+ create_chat_flow_endpoint(app, flow)
76
+ else:
77
+ self._create_flow_endpoint(app, flow)
39
78
 
40
79
  return app
41
80
 
@@ -49,11 +88,9 @@ class APIExecutor:
49
88
 
50
89
  # Create the endpoint function with proper model binding
51
90
  def execute_flow_endpoint(request: RequestModel) -> ResponseModel: # type: ignore
52
- """Execute the specific flow with provided inputs."""
53
91
  try:
54
92
  # Make a copy of the flow to avoid modifying the original
55
- # TODO: just store this in case we're using memory / need state.
56
- # TODO: Store memory and session info in a cache to enable this kind of stateful communication.
93
+ # TODO: Use session to ensure memory is not used across requests.
57
94
  flow_copy = flow.model_copy(deep=True)
58
95
  # Set input values on the flow variables
59
96
  if flow_copy.inputs:
@@ -98,7 +135,6 @@ class APIExecutor:
98
135
  app.post(
99
136
  f"/flows/{flow_id}",
100
137
  tags=["flow"],
101
- summary=f"Execute {flow_id} flow",
102
- description=f"Execute the '{flow_id}' flow with the provided input parameters.",
138
+ description=flow.description or "Execute a flow",
103
139
  response_model=ResponseModel,
104
140
  )(execute_flow_endpoint)
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from collections.abc import Generator
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.responses import StreamingResponse
10
+
11
+ from qtype.dsl.base_types import PrimitiveTypeEnum
12
+ from qtype.dsl.domain_types import ChatContent, ChatMessage, MessageRole
13
+ from qtype.interpreter.chat.file_conversions import file_to_content
14
+ from qtype.interpreter.chat.vercel import (
15
+ ChatRequest,
16
+ ErrorChunk,
17
+ FinishChunk,
18
+ StartChunk,
19
+ TextDeltaChunk,
20
+ TextEndChunk,
21
+ TextStartChunk,
22
+ UIMessage,
23
+ )
24
+ from qtype.interpreter.flow import execute_flow
25
+ from qtype.interpreter.streaming_helpers import create_streaming_generator
26
+ from qtype.semantic.model import Flow
27
+
28
+
29
+ def _ui_request_to_domain_type(request: ChatRequest) -> list[ChatMessage]:
30
+ """
31
+ Convert a ChatRequest to domain-specific ChatMessages.
32
+
33
+ Processes all UI messages from the AI SDK UI/React request format.
34
+ Returns the full conversation history for context.
35
+ """
36
+ if not request.messages:
37
+ raise ValueError("No messages provided in request.")
38
+
39
+ # Convert each UIMessage to a domain-specific ChatMessage
40
+ return [
41
+ _ui_message_to_domain_type(message) for message in request.messages
42
+ ]
43
+
44
+
45
+ def _ui_message_to_domain_type(message: UIMessage) -> ChatMessage:
46
+ """
47
+ Convert a UIMessage to a domain-specific ChatMessage.
48
+
49
+ Creates one block for each part in the message content.
50
+ """
51
+ blocks = []
52
+
53
+ for part in message.parts:
54
+ if part.type == "text":
55
+ blocks.append(
56
+ ChatContent(type=PrimitiveTypeEnum.text, content=part.text)
57
+ )
58
+ elif part.type == "reasoning":
59
+ blocks.append(
60
+ ChatContent(type=PrimitiveTypeEnum.text, content=part.text)
61
+ )
62
+ elif part.type == "file":
63
+ blocks.append(
64
+ file_to_content(part.url) # type: ignore
65
+ )
66
+ elif part.type.startswith("tool-"):
67
+ raise NotImplementedError(
68
+ "Tool call part handling is not implemented yet."
69
+ )
70
+ elif part.type == "dynamic-tool":
71
+ raise NotImplementedError(
72
+ "Dynamic tool part handling is not implemented yet."
73
+ )
74
+ elif part.type == "step-start":
75
+ # Step boundaries might not need content blocks
76
+ continue
77
+ elif part.type in ["source-url", "source-document"]:
78
+ raise NotImplementedError(
79
+ "Source part handling is not implemented yet."
80
+ )
81
+ elif part.type.startswith("data-"):
82
+ raise NotImplementedError(
83
+ "Data part handling is not implemented yet."
84
+ )
85
+ else:
86
+ # Log unknown part types for debugging
87
+ raise ValueError(f"Unknown part type: {part.type}")
88
+
89
+ # If no blocks were created, raise an error
90
+ if not blocks:
91
+ raise ValueError(
92
+ "No valid content blocks created from UIMessage parts."
93
+ )
94
+
95
+ return ChatMessage(
96
+ role=MessageRole(message.role),
97
+ blocks=blocks,
98
+ )
99
+
100
+
101
+ def create_chat_flow_endpoint(app: FastAPI, flow: Flow) -> None:
102
+ """
103
+ Create a chat endpoint for the given Flow.
104
+
105
+ This creates an endpoint at /flows/{flow_id}/chat that follows the
106
+ AI SDK UI/React request format and responds with streaming data.
107
+
108
+ Args:
109
+ app: The FastAPI application instance
110
+ flow: The Flow to create an endpoint for
111
+ """
112
+ flow_id = flow.id
113
+
114
+ async def handle_chat_data(request: ChatRequest) -> StreamingResponse:
115
+ """Handle chat requests for the specific flow."""
116
+
117
+ try:
118
+ # Convert AI SDK UI request to domain ChatMessages
119
+ messages = _ui_request_to_domain_type(request)
120
+ if not len(messages):
121
+ raise ValueError("No input messages received")
122
+
123
+ # Pop the last message as the current input
124
+ current_input = messages.pop()
125
+ if current_input.role != MessageRole.user:
126
+ raise ValueError(
127
+ f"Unexpected input {current_input} from non user role: {current_input.role}"
128
+ )
129
+
130
+ flow_copy = flow.model_copy(deep=True)
131
+
132
+ input_variable = [
133
+ var for var in flow_copy.inputs if var.type == ChatMessage
134
+ ][0]
135
+ input_variable.value = current_input
136
+
137
+ # Pass conversation context to flow execution for memory population
138
+ execution_kwargs: Any = {
139
+ "session_id": request.id, # Use request ID as session identifier
140
+ "conversation_history": messages,
141
+ }
142
+
143
+ # Create a streaming generator for the flow execution
144
+ stream_generator, result_future = create_streaming_generator(
145
+ execute_flow, flow_copy, **execution_kwargs
146
+ )
147
+ except Exception as e:
148
+ error_chunk = ErrorChunk(errorText=str(e))
149
+ response = StreamingResponse(
150
+ [
151
+ f"data: {error_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
152
+ ],
153
+ media_type="text/plain; charset=utf-8",
154
+ )
155
+ response.headers["x-vercel-ai-ui-message-stream"] = "v1"
156
+ return response
157
+
158
+ # Create generator that formats messages according to AI SDK UI streaming protocol
159
+ def vercel_ai_formatter() -> Generator[str, None, None]:
160
+ """Format stream data according to AI SDK UI streaming protocol."""
161
+
162
+ # Send start chunk
163
+ start_chunk = StartChunk(messageId=str(uuid.uuid4())) # type: ignore
164
+ yield f"data: {start_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
165
+
166
+ # Track text content for proper streaming
167
+ text_id = str(uuid.uuid4())
168
+ text_started = False
169
+
170
+ for step, message in stream_generator:
171
+ if isinstance(message, ChatMessage):
172
+ # Convert ChatMessage to text content
173
+ content = " ".join(
174
+ [
175
+ block.content
176
+ for block in message.blocks
177
+ if hasattr(block, "content") and block.content
178
+ ]
179
+ )
180
+ if content.strip():
181
+ # Start text block if not started
182
+ if not text_started:
183
+ text_start = TextStartChunk(id=text_id)
184
+ yield f"data: {text_start.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
185
+ text_started = True
186
+
187
+ # Send text delta
188
+ text_delta = TextDeltaChunk(id=text_id, delta=content)
189
+ yield f"data: {text_delta.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
190
+ else:
191
+ # Handle other message types as text deltas
192
+ text_content = str(message)
193
+ if text_content.strip():
194
+ # Start text block if not started
195
+ if not text_started:
196
+ text_start = TextStartChunk(id=text_id)
197
+ yield f"data: {text_start.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
198
+ text_started = True
199
+
200
+ # Send text delta
201
+ text_delta = TextDeltaChunk(
202
+ id=text_id, delta=text_content
203
+ )
204
+ yield f"data: {text_delta.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
205
+
206
+ # End text block if it was started
207
+ if text_started:
208
+ text_end = TextEndChunk(id=text_id)
209
+ yield f"data: {text_end.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
210
+
211
+ # Send finish chunk
212
+ try:
213
+ result_future.result(timeout=5.0)
214
+ finish_chunk = FinishChunk()
215
+ yield f"data: {finish_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
216
+ except Exception as e:
217
+ # Send error
218
+ error_chunk = ErrorChunk(errorText=str(e))
219
+ logging.error(
220
+ f"Error during flow execution: {e}", exc_info=True
221
+ )
222
+ yield f"data: {error_chunk.model_dump_json(by_alias=True, exclude_none=True)}\n\n"
223
+
224
+ response = StreamingResponse(
225
+ vercel_ai_formatter(), media_type="text/plain; charset=utf-8"
226
+ )
227
+ response.headers["x-vercel-ai-ui-message-stream"] = "v1"
228
+ return response
229
+
230
+ # Add the endpoint to the FastAPI app
231
+ app.post(
232
+ f"/flows/{flow_id}/chat",
233
+ tags=["chat"],
234
+ summary=f"Chat with {flow_id} flow",
235
+ description=flow.description,
236
+ response_class=StreamingResponse,
237
+ )(handle_chat_data)
@@ -0,0 +1,57 @@
1
+ import base64
2
+
3
+ import magic
4
+ import requests
5
+
6
+ from qtype.dsl.base_types import PrimitiveTypeEnum
7
+ from qtype.dsl.domain_types import ChatContent
8
+
9
+
10
+ def file_to_content(url: str) -> ChatContent:
11
+ """
12
+ Convert a file URL to a ChatContent block.
13
+
14
+ Args:
15
+ url: The URL of the file.
16
+
17
+ Returns:
18
+ A ChatContent block with type 'file' and the file URL as content.
19
+ """
20
+
21
+ # Get the bytes from the url.
22
+ if url.startswith("data:"):
23
+ # strip the `data:` prefix and decode the base64 content
24
+ b64content = url[len("data:") :].split(",", 1)[1]
25
+ content = base64.b64decode(b64content)
26
+ else:
27
+ content = requests.get(url).content
28
+
29
+ # Determine the mime type using python-magic
30
+ media_type = magic.from_buffer(content, mime=True)
31
+
32
+ if media_type.startswith("image/"):
33
+ return ChatContent(
34
+ type=PrimitiveTypeEnum.image, content=content, mime_type=media_type
35
+ )
36
+ elif media_type.startswith("video/"):
37
+ return ChatContent(
38
+ type=PrimitiveTypeEnum.video, content=content, mime_type=media_type
39
+ )
40
+ elif media_type.startswith("audio/"):
41
+ return ChatContent(
42
+ type=PrimitiveTypeEnum.audio, content=content, mime_type=media_type
43
+ )
44
+ elif media_type.startswith("text/"):
45
+ return ChatContent(
46
+ type=PrimitiveTypeEnum.text,
47
+ content=content.decode("utf-8"),
48
+ mime_type=media_type,
49
+ )
50
+ elif media_type.startswith("document/"):
51
+ return ChatContent(
52
+ type=PrimitiveTypeEnum.file, content=content, mime_type=media_type
53
+ )
54
+ else:
55
+ return ChatContent(
56
+ type=PrimitiveTypeEnum.bytes, content=content, mime_type=media_type
57
+ )