Skip to content

Commit 7cafd65

Browse files
hyobanclaudeautofix-ci[bot]
authored
refactor(web): migrate handwritten console API calls to the generated oRPC contract (#44169)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 1028543 commit 7cafd65

819 files changed

Lines changed: 16995 additions & 15010 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎api/controllers/common/fields.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from graphon.file import helpers as file_helpers
99
from models.model import IconType
1010

11-
type JSONValue = str | int | float | bool | None | dict[str, Any] | list[Any]
11+
type ShallowJSONValue = str | int | float | bool | None | dict[str, Any] | list[Any]
1212
type JSONObject = dict[str, Any]
1313
URLString = Annotated[str, WithJsonSchema({"format": "url", "type": "string"})]
1414
UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})]
@@ -335,8 +335,8 @@ class SimpleResultResponse(ResponseModel):
335335
result: str = Field(description="Operation result.")
336336

337337

338-
class GeneratedAppResponse(RootModel[JSONValue]):
339-
root: JSONValue
338+
class GeneratedAppResponse(RootModel[ShallowJSONValue]):
339+
root: ShallowJSONValue
340340

341341

342342
class EventStreamResponse(RootModel[str]):
@@ -360,7 +360,7 @@ class AudioBinaryResponse(RootModel[bytes]):
360360

361361

362362
class AudioTranscriptResponse(ResponseModel):
363-
text: str
363+
text: str = Field(description="Transcribed text from audio")
364364

365365

366366
class ValidationResultResponse(ResponseModel):

‎api/controllers/common/schema.py‎

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
`register_schema_models` emit resolvable OpenAPI 3 references.
77
"""
88

9+
import re
910
from collections.abc import Iterable, Mapping
1011
from enum import StrEnum
11-
from typing import Any, Literal, NotRequired, Protocol, TypedDict
12+
from typing import Any, Literal, NotRequired, Protocol, TypedDict, override
13+
from weakref import WeakKeyDictionary
1214

1315
from flask import request
1416
from flask_restx import Namespace
1517
from pydantic import BaseModel, TypeAdapter
18+
from pydantic.json_schema import CoreModeRef, DefsRef, GenerateJsonSchema
1619

1720
DEFAULT_REF_TEMPLATE_OPENAPI_3_0 = "#/components/schemas/{model}"
1821

@@ -51,39 +54,125 @@ def to_dict(self, flat: bool = True) -> dict[str, str]: ...
5154
def getlist(self, key: str) -> list[str]: ...
5255

5356

54-
def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None:
55-
"""Register a JSON schema and promote any nested Pydantic `$defs`."""
57+
class SchemaNameCollisionError(ValueError):
58+
"""Raised when two different schemas would be published under the same OpenAPI component name."""
59+
60+
61+
# Pydantic names `$defs` after the bare class name and only qualifies them when two
62+
# same-named types meet inside one schema, so the same type can surface under different
63+
# names depending on which model pulled it in. Types that share a class name with
64+
# another documented type get a fixed, unique component name here instead.
65+
SCHEMA_NAME_OVERRIDES: Mapping[str, str] = {
66+
"graphon.model_runtime.entities.common_entities.I18nObject": "ModelRuntimeI18nObject",
67+
"services.knowledge.entities.knowledge_entities.FileInfo": "KnowledgeFileInfo",
68+
"core.plugin.entities.plugin.PluginDependency.Github": "PluginDependencyGithub",
69+
"core.plugin.entities.plugin.PluginDependency.Marketplace": "PluginDependencyMarketplace",
70+
"core.plugin.entities.plugin.PluginDependency.Package": "PluginDependencyPackage",
71+
"core.plugin.entities.bundle.PluginBundleDependency.Github": "PluginBundleDependencyGithub",
72+
"core.plugin.entities.bundle.PluginBundleDependency.Marketplace": "PluginBundleDependencyMarketplace",
73+
"core.plugin.entities.bundle.PluginBundleDependency.Package": "PluginBundleDependencyPackage",
74+
"core.app.entities.task_entities.WorkflowAppBlockingResponse.Data": "WorkflowAppBlockingResponseData",
75+
}
76+
77+
_REF_ID_SUFFIX = re.compile(r":\d+(?=$|[\[\],])")
78+
_MODULE_PREFIX = re.compile(r"(?:[^.[\],]+\.)+([^.[\],]+)")
79+
80+
# Normalized component name -> (component name, fully qualified source type, schema), per namespace.
81+
_registered_schemas: WeakKeyDictionary[Namespace, dict[str, tuple[str, str, dict]]] = WeakKeyDictionary()
82+
_NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]")
83+
84+
85+
def _normalized_schema_name(name: str) -> str:
86+
"""Fold names that client code generators turn into the same identifier (`JSONValue` / `JsonValue`)."""
87+
88+
return _NON_ALPHANUMERIC.sub("", name.lower())
89+
90+
91+
class _OpenApiJsonSchemaGenerator(GenerateJsonSchema):
92+
"""Emit stable component names and remember which type produced each of them."""
93+
94+
def __init__(self, *args: Any, **kwargs: Any) -> None:
95+
super().__init__(*args, **kwargs)
96+
self.schema_sources: dict[str, str] = {}
97+
98+
@override
99+
def get_defs_ref(self, core_mode_ref: CoreModeRef) -> DefsRef:
100+
core_ref, _mode = core_mode_ref
101+
source = _REF_ID_SUFFIX.sub("", core_ref)
102+
name = SCHEMA_NAME_OVERRIDES.get(source) or self.normalize_name(_MODULE_PREFIX.sub(r"\1", source))
103+
existing_source = self.schema_sources.setdefault(name, source)
104+
if existing_source != source:
105+
raise SchemaNameCollisionError(
106+
f"OpenAPI schema name {name!r} is produced by both {existing_source} and {source}; "
107+
"rename one of them or add it to SCHEMA_NAME_OVERRIDES."
108+
)
109+
defs_ref = DefsRef(name)
110+
self._prioritized_defsref_choices[defs_ref] = [defs_ref]
111+
return defs_ref
112+
113+
114+
def _register_json_schema(namespace: Namespace, name: str, schema: dict, sources: Mapping[str, str]) -> None:
115+
"""Register a JSON schema and promote any nested Pydantic `$defs`.
116+
117+
`sources` maps each component name to the type that produced it. Flask-RESTX keeps
118+
one flat model namespace, so a second registration under an existing name would
119+
silently replace the first. Re-registering the same type with the same schema is a
120+
no-op; a different type, or the same type with a different shape (for example a
121+
response model also registered in validation mode), raises instead.
122+
"""
56123

57124
nested_definitions = schema.get("$defs")
58125
schema_to_register = dict(schema)
59126
if isinstance(nested_definitions, dict):
60127
schema_to_register.pop("$defs")
61128

129+
source = sources.get(name, name)
130+
registered = _registered_schemas.setdefault(namespace, {})
131+
normalized_name = _normalized_schema_name(name)
132+
existing = registered.get(normalized_name)
133+
if existing is not None and existing != (name, source, schema_to_register):
134+
existing_name, existing_source, _ = existing
135+
if existing_name != name:
136+
reason = f"collides with {existing_name!r} from {existing_source} once normalized by client generators"
137+
elif existing_source != source:
138+
reason = f"is produced by both {existing_source} and {source}"
139+
else:
140+
reason = f"is registered by {source} with two different shapes (validation vs serialization mode?)"
141+
raise SchemaNameCollisionError(
142+
f"OpenAPI schema name {name!r} in namespace {namespace.name!r} {reason}; give each shape its own "
143+
"class name, add it to SCHEMA_NAME_OVERRIDES, or register response models only with "
144+
"register_response_schema_models."
145+
)
146+
registered[normalized_name] = (name, source, schema_to_register)
147+
62148
namespace.schema_model(name, schema_to_register)
63149

64150
if not isinstance(nested_definitions, dict):
65151
return
66152

67153
for nested_name, nested_schema in nested_definitions.items():
68154
if isinstance(nested_schema, dict):
69-
_register_json_schema(namespace, nested_name, nested_schema)
155+
_register_json_schema(namespace, nested_name, nested_schema, sources)
70156

71157

72158
JsonSchemaMode = Literal["validation", "serialization"]
73159

74160

75-
def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode: JsonSchemaMode) -> None:
161+
def _register_type_schema(namespace: Namespace, source_type: type, *, mode: JsonSchemaMode) -> None:
162+
generator = _OpenApiJsonSchemaGenerator(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
163+
schema = generator.generate(TypeAdapter(source_type).core_schema, mode=mode)
76164
_register_json_schema(
77165
namespace,
78-
model.__name__,
79-
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0, mode=mode),
166+
source_type.__name__,
167+
schema,
168+
{**generator.schema_sources, source_type.__name__: f"{source_type.__module__}.{source_type.__qualname__}"},
80169
)
81170

82171

83172
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
84173
"""Register a BaseModel and its nested component schemas for OpenAPI documentation."""
85174

86-
_register_schema_model(namespace, model, mode="validation")
175+
_register_type_schema(namespace, model, mode="validation")
87176

88177

89178
def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None:
@@ -96,7 +185,7 @@ def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> No
96185
def register_response_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
97186
"""Register a BaseModel using its serialized response shape."""
98187

99-
_register_schema_model(namespace, model, mode="serialization")
188+
_register_type_schema(namespace, model, mode="serialization")
100189

101190

102191
def register_response_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None:
@@ -119,11 +208,7 @@ def get_or_create_model(model_name: str, field_def):
119208
def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None:
120209
"""Register multiple StrEnum with a namespace."""
121210
for model in models:
122-
_register_json_schema(
123-
namespace,
124-
model.__name__,
125-
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
126-
)
211+
_register_type_schema(namespace, model, mode="validation")
127212

128213

129214
def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]:
@@ -334,6 +419,8 @@ def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str
334419

335420
__all__ = [
336421
"DEFAULT_REF_TEMPLATE_OPENAPI_3_0",
422+
"SCHEMA_NAME_OVERRIDES",
423+
"SchemaNameCollisionError",
337424
"get_or_create_model",
338425
"query_params_from_model",
339426
"query_params_from_request",

‎api/controllers/console/app/advanced_prompt_template.py‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any
1+
from typing import Literal
22

33
from flask_restx import Resource
44
from pydantic import BaseModel, Field
@@ -23,9 +23,33 @@ class AdvancedPromptTemplateQuery(BaseModel):
2323
model_name: str = Field(..., description="Model name")
2424

2525

26+
class PromptTemplateMessageResponse(ResponseModel):
27+
role: Literal["system", "user", "assistant"]
28+
text: str
29+
30+
31+
class ChatPromptTemplateResponse(ResponseModel):
32+
prompt: list[PromptTemplateMessageResponse]
33+
34+
35+
class CompletionPromptTemplateTextResponse(ResponseModel):
36+
text: str
37+
38+
39+
class ConversationHistoriesRoleResponse(ResponseModel):
40+
user_prefix: str
41+
assistant_prefix: str
42+
43+
44+
class CompletionPromptTemplateResponse(ResponseModel):
45+
prompt: CompletionPromptTemplateTextResponse
46+
conversation_histories_role: ConversationHistoriesRoleResponse | None = None
47+
48+
2649
class AdvancedPromptTemplateResponse(ResponseModel):
27-
chat_prompt_config: dict[str, Any] | None = Field(default=None)
28-
completion_prompt_config: dict[str, Any] | None = Field(default=None)
50+
chat_prompt_config: ChatPromptTemplateResponse | None = Field(default=None)
51+
completion_prompt_config: CompletionPromptTemplateResponse | None = Field(default=None)
52+
stop: list[str] | None = Field(default=None, description="Stop words for completion prompts.")
2953

3054

3155
console_ns.schema_model(

‎api/controllers/console/app/agent.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ class AgentToolCallResponse(ResponseModel):
4848
error: str | None = None
4949
time_cost: float | int
5050
tool_name: str
51-
tool_label: str
51+
tool_label: str | dict[str, str] = Field(description="Tool name or its i18n labels keyed by locale")
5252
tool_input: dict[str, Any]
5353
tool_output: dict[str, Any]
5454
tool_parameters: dict[str, Any]
55-
tool_icon: Any = Field(default=None)
55+
tool_icon: str | dict[str, str] | None = Field(default=None, description="Icon URL or emoji icon")
5656

5757

5858
class AgentIterationLogResponse(ResponseModel):

‎api/controllers/console/app/annotation.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ class AnnotationListQuery(BaseModel):
7676
keyword: str = Field(default="", description="Search keyword")
7777

7878

79+
class AnnotationBatchDeleteQuery(BaseModel):
80+
annotation_id: list[str] = Field(
81+
default_factory=list,
82+
description="Annotation IDs to delete; omitting it clears all annotations of the app",
83+
)
84+
85+
7986
class CreateAnnotationPayload(BaseModel):
8087
message_id: str | None = Field(default=None, description="Message ID")
8188
question: str | None = Field(default=None, description="Question text")
@@ -140,11 +147,6 @@ class AnnotationBatchImportResponse(ResponseModel):
140147

141148
register_schema_models(
142149
console_ns,
143-
Annotation,
144-
AnnotationList,
145-
AnnotationExportList,
146-
AnnotationHitHistory,
147-
AnnotationHitHistoryList,
148150
AnnotationReplyPayload,
149151
AnnotationSettingUpdatePayload,
150152
AnnotationListQuery,
@@ -349,6 +351,7 @@ def post(self, req_data: CreateAnnotationPayload, session: Session, app_id: UUID
349351
@account_initialization_required
350352
@edit_permission_required
351353
@rbac_permission_required(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp()))
354+
@console_ns.doc(params=query_params_from_model(AnnotationBatchDeleteQuery))
352355
@console_ns.response(204, "Annotations deleted successfully")
353356
@with_session
354357
def delete(self, session: Session, app_id: UUID):

‎api/controllers/console/app/app.py‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
from extensions.ext_application_services import application_services
3333
from fields.app_fields import (
3434
AppDetail,
35-
AppDetailSiteResponse,
3635
AppDetailWithSite,
3736
AppExportResponse,
3837
AppImportResponse,
@@ -41,7 +40,6 @@
4140
AppPartial,
4241
AppTraceResponse,
4342
DeletedTool,
44-
ModelConfigPartial,
4543
RecentAppListResponse,
4644
RecentAppResponse,
4745
Tag,
@@ -269,8 +267,6 @@ def validate_tracing_provider(cls, value: str | None, info) -> str | None:
269267
AppTracePayload,
270268
Tag,
271269
WorkflowPartial,
272-
ModelConfigPartial,
273-
AppDetailSiteResponse,
274270
DeletedTool,
275271
AppExportResponse,
276272
Segmentation,

‎api/controllers/console/app/audio.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from werkzeug.exceptions import HTTPException, InternalServerError
1010

1111
import services
12+
from controllers.common.fields import AudioTranscriptResponse
1213
from controllers.common.rbac import AgentId, PlainApp, RBACCheck, enforce_rbac_checks
1314
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
1415
from controllers.console import console_ns
@@ -79,10 +80,6 @@ class TextToSpeechVoiceQuery(BaseModel):
7980
language: str = Field(..., description="Language code")
8081

8182

82-
class AudioTranscriptResponse(ResponseModel):
83-
text: str = Field(description="Transcribed text from audio")
84-
85-
8683
class AgentAudioTranscriptFormPayload(BaseModel):
8784
model_config = ConfigDict(extra="forbid")
8885

0 commit comments

Comments
 (0)